Merge branch 'master' into dependabot/github_actions/dependencies-4e575e2008

This commit is contained in:
Matthias Mair
2026-07-16 14:34:14 -07:00
committed by GitHub
124 changed files with 138107 additions and 127033 deletions
+2
View File
@@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- [#12391](https://github.com/inventree/InvenTree/pull/12391) adds facility for bulk deleting line items against orders
- [#12388](https://github.com/inventree/InvenTree/pull/12388) adds uniqueness requirements options for the Parameter and ParameterTemplate models. This allows users to specify whether a parameter value should be unique for a given model type, or globally unique across all models.
- [#12310](https://github.com/inventree/InvenTree/pull/12310) adds the ability to disassemble (or break apart) assembled stock items into their component parts, based on the Bill of Materials (BOM) associated with the stock item. This allows users to easily break down assembled items into their constituent parts, which can be useful for inventory management and tracking purposes. - [#12310](https://github.com/inventree/InvenTree/pull/12310) adds the ability to disassemble (or break apart) assembled stock items into their component parts, based on the Bill of Materials (BOM) associated with the stock item. This allows users to easily break down assembled items into their constituent parts, which can be useful for inventory management and tracking purposes.
- [#12117](https://github.com/inventree/InvenTree/pull/12117) adds a "preview" drawer to the InvenTree table component, allowing users to preview the details of a selected row without navigating away from the table view. This feature is optional and can be enabled or disabled via the `PREVIEW_DRAWER_ENABLED` system setting. - [#12117](https://github.com/inventree/InvenTree/pull/12117) adds a "preview" drawer to the InvenTree table component, allowing users to preview the details of a selected row without navigating away from the table view. This feature is optional and can be enabled or disabled via the `PREVIEW_DRAWER_ENABLED` system setting.
- [#12341](https://github.com/inventree/InvenTree/pull/12341) adds support for importing internal part prices. - [#12341](https://github.com/inventree/InvenTree/pull/12341) adds support for importing internal part prices.
+40
View File
@@ -30,6 +30,7 @@ Parameter templates are used to define the different types of parameters which a
| Choices | A comma-separated list of valid choices for parameter values linked to this template. | | Choices | A comma-separated list of valid choices for parameter values linked to this template. |
| Checkbox | If set, parameters linked to this template can only be assigned values *true* or *false* | | Checkbox | If set, parameters linked to this template can only be assigned values *true* or *false* |
| Selection List | If set, parameters linked to this template can only be assigned values from the linked [selection list](#selection-lists) | | Selection List | If set, parameters linked to this template can only be assigned values from the linked [selection list](#selection-lists) |
| Unique | Enforce a [uniqueness requirement](#parameter-uniqueness) on parameter values linked to this template |
{{ image("concepts/parameter-template.png", "Parameters Template") }} {{ image("concepts/parameter-template.png", "Parameters Template") }}
@@ -59,6 +60,45 @@ To add a parameter, navigate to a specific part detail page, click on the "Param
Select the parameter `Template` you would like to use for this parameter, fill-out the `Data` field (value of this specific parameter) and click the "Submit" button. Select the parameter `Template` you would like to use for this parameter, fill-out the `Data` field (value of this specific parameter) and click the "Submit" button.
### Parameter Uniqueness
A parameter template can be configured to enforce a uniqueness requirement on the values of any parameters linked to it. This is useful for parameters which are expected to act as a unique identifier and prevents duplicate values from being entered by mistake.
The `Unique` attribute on a parameter template supports the following options:
| Option | Description |
| --- | --- |
| No uniqueness required | The default option - no restriction is placed on parameter values |
| Unique for model type | A parameter value must be unique amongst all other parameters (linked to this template) which are assigned to the *same* model type. For example, a template with this option enabled could be used to enforce unique serial numbers across all `Part` instances, without preventing the same value from also being used against a `Company` instance |
| Globally unique | A parameter value must be unique amongst *all* other parameters linked to this template, regardless of the model type to which they are assigned |
!!! info "Case Insensitive"
Uniqueness checks are case-insensitive - for example, the values `ABC123` and `abc123` are considered to be duplicates of each other.
If a parameter value is entered which does not satisfy the uniqueness requirement of its template, it will be rejected and an error message displayed.
#### Validation Against Numeric Value
If a parameter template defines a set of [units](#parameter-units), uniqueness is checked against the *normalized numeric value* of the parameter, rather than the raw text entered. This ensures that equivalent values expressed in different (but compatible) units are correctly detected as duplicates.
For example, if a *Resistance* template (with base units of `ohm`) enforces uniqueness, then a value of `1k` would be rejected as a duplicate of an existing value of `1000`, as they both represent the same physical quantity.
Templates which do not define a set of units are compared using a direct (case-insensitive) text comparison of the raw parameter value.
#### Copying Caveats
Some workflows in InvenTree allow parameters to be copied from one object to another - for example, when duplicating a part, build order, or other object which supports parameters.
Any parameter which is linked to a template with a uniqueness requirement is *skipped* when copying parameters in this way. This prevents the copy operation from creating a duplicate (and therefore invalid) value on the new object. If this occurs, the new object simply will not have a parameter created against that particular template - it can be added manually (with a distinct value) afterwards.
#### Category Parameter Caveats
A parameter template can be linked to a part category, along with a default value which is automatically applied to any new parts created within that category (or a sub-category).
This default value system is not compatible with a template which enforces a uniqueness requirement - applying the *same* default value to every part in a category would immediately conflict with the "unique" requirement, as soon as more than one part exists in that category.
For this reason, a category-based default value is *not* applied for any parameter template which has a uniqueness requirement configured. If you need to assign values for such parameters, this must be done manually (or via the API) on a per-part basis.
## Parametric Tables ## Parametric Tables
Parametric tables gather all parameters from all objects of a particular type, to be sorted and filtered. Parametric tables gather all parameters from all objects of a particular type, to be sorted and filtered.
+1
View File
@@ -148,3 +148,4 @@ The following [global settings](../settings/global.md) are available for transfe
{{ globalsetting("TRANSFERORDER_ENABLED") }} {{ globalsetting("TRANSFERORDER_ENABLED") }}
{{ globalsetting("TRANSFERORDER_REFERENCE_PATTERN") }} {{ globalsetting("TRANSFERORDER_REFERENCE_PATTERN") }}
{{ globalsetting("TRANSFERORDER_REQUIRE_RESPONSIBLE") }} {{ globalsetting("TRANSFERORDER_REQUIRE_RESPONSIBLE") }}
{{ globalsetting("TRANSFERORDER_EDIT_COMPLETED_ORDERS") }}
+31 -6
View File
@@ -6,6 +6,7 @@ from pathlib import Path
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction from django.db import transaction
from django.http import JsonResponse from django.http import JsonResponse
from django.urls import path, reverse from django.urls import path, reverse
@@ -30,6 +31,8 @@ from common.settings import get_global_setting
from InvenTree import helpers, ready from InvenTree import helpers, ready
from InvenTree.auth_overrides import registration_enabled from InvenTree.auth_overrides import registration_enabled
from InvenTree.mixins import ListCreateAPI from InvenTree.mixins import ListCreateAPI
from InvenTree.tasks import batch_offload_tasks
from plugin.base.event.events import batch_events
from plugin.serializers import MetadataSerializer from plugin.serializers import MetadataSerializer
from users.models import ApiToken from users.models import ApiToken
from users.permissions import check_user_permission, prefetch_rule_sets from users.permissions import check_user_permission, prefetch_rule_sets
@@ -510,7 +513,7 @@ class BulkCreateMixin:
if has_unique_errors: if has_unique_errors:
raise ValidationError(unique_errors) raise ValidationError(unique_errors)
with transaction.atomic(): with transaction.atomic(), batch_events(), batch_offload_tasks():
for item in data: for item in data:
serializer = self.get_serializer(data=item) serializer = self.get_serializer(data=item)
if serializer.is_valid(): if serializer.is_valid():
@@ -534,8 +537,12 @@ class BulkUpdateMixin(BulkOperationMixin):
Bulk update allows for multiple items to be updated in a single API query, Bulk update allows for multiple items to be updated in a single API query,
rather than using multiple API calls to the various detail endpoints. rather than using multiple API calls to the various detail endpoints.
Each instance is validated and saved individually, so that any custom save methods are triggered.
""" """
BULK_ID_FIELD: str = 'pk'
def validate_update(self, queryset, request) -> None: def validate_update(self, queryset, request) -> None:
"""Perform validation right before updating. """Perform validation right before updating.
@@ -579,17 +586,35 @@ class BulkUpdateMixin(BulkOperationMixin):
# Perform the update operation # Perform the update operation
data = request.data data = request.data
n = queryset.count() # Extract the primary key values up-front:
# Each instance is re-fetched from the database immediately before it is
# updated, as saving one instance may alter database state which other
# instances in the queryset depend on (e.g. MPTT tree structure fields).
# Saving a stale instance can result in database corruption (and it must
# be the *instance* that is fresh - refresh_from_db is not sufficient here,
# as MPTT caches original field values when the instance is loaded).
pk_values = sorted(queryset.values_list(self.BULK_ID_FIELD, flat=True))
instance_data = [] instance_data = []
with transaction.atomic(): with transaction.atomic(), batch_events(), batch_offload_tasks():
# Perform object update # Perform object update
# Note that we do not perform a bulk-update operation here, # Note that we do not perform a bulk-update operation here,
# as we want to trigger any custom post_save methods on the model # as we want to trigger any custom post_save methods on the model
# Run validation first # Run validation first
for instance in queryset: for pk in pk_values:
try:
instance = queryset.select_for_update(of=('self',)).get(**{
self.BULK_ID_FIELD: pk
})
except ObjectDoesNotExist:
raise ValidationError({
'non_field_errors': _(
'Item no longer matches the provided criteria'
)
})
serializer = self.get_serializer(instance, data=data, partial=True) serializer = self.get_serializer(instance, data=data, partial=True)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
serializer.save() serializer.save()
@@ -597,7 +622,7 @@ class BulkUpdateMixin(BulkOperationMixin):
instance_data.append(serializer.data) instance_data.append(serializer.data)
return Response( return Response(
{'success': f'Updated {n} items', 'items': instance_data}, status=200 {'success': 'Updated multiple items', 'items': instance_data}, status=200
) )
@@ -669,7 +694,7 @@ class CommonBulkDeleteMixin(BulkOperationMixin):
# Keep track of how many items we deleted # Keep track of how many items we deleted
n_deleted = queryset.count() n_deleted = queryset.count()
with transaction.atomic(): with transaction.atomic(), batch_events(), batch_offload_tasks():
# Perform object deletion # Perform object deletion
# Note that we do not perform a bulk-delete operation here, # Note that we do not perform a bulk-delete operation here,
# as we want to trigger any custom post_delete methods on the model # as we want to trigger any custom post_delete methods on the model
@@ -1,11 +1,19 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 521 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v523 -> 2026-07-14 : https://github.com/inventree/InvenTree/pull/12391
- Adds "bulk delete" support for order line item API endpoints (PurchaseOrder / SalesOrder / ReturnOrder / TransferOrder)
- Adds "bulk delete" support for order extra line item API endpoints
- Completed TransferOrder objects are now "locked" (controlled by the new TRANSFERORDER_EDIT_COMPLETED_ORDERS global setting)
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 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. - Removes the MPTT mixin from the StockItem model, and removes the self-referential tree structure from the database.
+135 -34
View File
@@ -597,12 +597,21 @@ class InvenTreeParameterMixin(InvenTreePermissionCheckMixin, models.Model):
content_type = ContentType.objects.get_for_model(self.__class__) 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 # Remove all conflicting parameters first
self.parameters_list.filter(template__pk__in=template_ids).delete() 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.pk = None
parameter.model_id = self.pk parameter.model_id = self.pk
parameter.model_type = content_type parameter.model_type = content_type
@@ -735,6 +744,7 @@ class InvenTreeTree(ContentTypeMixin, MPTTModel):
order_insertion_by = ['name'] order_insertion_by = ['name']
@transaction.atomic
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
"""Handle the deletion of a tree node. """Handle the deletion of a tree node.
@@ -805,18 +815,7 @@ class InvenTreeTree(ContentTypeMixin, MPTTModel):
next_tree_id += 1 next_tree_id += 1
# 3. Rebuild the model tree(s) as required # 3. Rebuild the model tree(s) as required
# - If any partial rebuilds fail, we will rebuild the entire tree self.__class__.rebuild_trees(trees)
result = True
for tree_id in trees:
if tree_id:
if not self.partial_rebuild(tree_id):
result = False
if not result:
# Rebuild the entire tree (expensive!!!)
self.__class__.objects.rebuild()
def handle_tree_delete(self, delete_children=False, delete_items=False): def handle_tree_delete(self, delete_children=False, delete_items=False):
"""Delete a single instance of the tree, based on provided kwargs. """Delete a single instance of the tree, based on provided kwargs.
@@ -933,12 +932,18 @@ class InvenTreeTree(ContentTypeMixin, MPTTModel):
# New instance, so we need to rebuild the tree (if it has a parent) # New instance, so we need to rebuild the tree (if it has a parent)
trees.add(self.tree_id) trees.add(self.tree_id)
for tree_id in trees: # Flag to indicate that a tree rebuild task was triggered by this save
if tree_id: self._tree_rebuild_offloaded = False
self.partial_rebuild(tree_id)
if len(trees) > 0: if len(trees) > 0:
# A tree update was performed, so we need to refresh the instance # Offload the tree rebuild(s) to the background worker.
# Note that repeated calls are de-duplicated (per tree),
# so a bulk operation results in a single rebuild per affected tree.
ran_sync = self.__class__.offload_tree_rebuild(trees)
self._tree_rebuild_offloaded = True
if ran_sync:
# The tree was rebuilt synchronously, so refresh the instance
try: try:
self.refresh_from_db() self.refresh_from_db()
except TransactionManagementError: except TransactionManagementError:
@@ -949,24 +954,67 @@ class InvenTreeTree(ContentTypeMixin, MPTTModel):
InvenTree.sentry.report_exception(e) InvenTree.sentry.report_exception(e)
InvenTree.exceptions.log_error(f'{self.__class__.__name__}.save') InvenTree.exceptions.log_error(f'{self.__class__.__name__}.save')
def partial_rebuild(self, tree_id: int) -> bool: @classmethod
def offload_tree_rebuild(cls, tree_ids) -> bool:
"""Offload a rebuild of the specified trees to the background worker.
- The tree structure (and pathstring values, where applicable) are rebuilt for each tree
- If the background worker is not running, the rebuild is performed synchronously
- Identical pending tasks are skipped, so repeated calls (e.g. during a bulk
operation) result in (at most) a single queued rebuild per affected tree
Returns:
bool: True if any rebuild was performed synchronously (in the calling thread)
"""
from InvenTree.tasks import offload_task
ran_sync = False
for tree_id in tree_ids:
if tree_id:
result = offload_task(
'InvenTree.tasks.rebuild_model_tree', cls._meta.label_lower, tree_id
)
if result is True:
# offload_task returns True if the task ran synchronously
ran_sync = True
return ran_sync
@classmethod
def rebuild_trees(cls, tree_ids) -> None:
"""Rebuild the specified trees, with fallback to a full rebuild.
- Perform a partial rebuild for each provided tree_id
- If any partial rebuild fails, rebuild the entire tree (expensive!!!)
"""
result = True
for tree_id in tree_ids:
if tree_id and not cls.partial_rebuild(tree_id):
result = False
if not result:
# Rebuild the entire tree (expensive!!!)
cls.objects.rebuild()
@classmethod
def partial_rebuild(cls, tree_id: int) -> bool:
"""Perform a partial rebuild of the tree structure. """Perform a partial rebuild of the tree structure.
If a failure occurs, log the error and return False. If a failure occurs, log the error and return False.
""" """
try: try:
self.__class__.objects.partial_rebuild(tree_id) cls.objects.partial_rebuild(tree_id)
return True return True
except Exception as e: except Exception as e:
# This is a critical error, explicitly report to sentry # This is a critical error, explicitly report to sentry
InvenTree.sentry.report_exception(e) InvenTree.sentry.report_exception(e)
InvenTree.exceptions.log_error(f'{self.__class__.__name__}.partial_rebuild') InvenTree.exceptions.log_error(f'{cls.__name__}.partial_rebuild')
logger.exception( logger.exception(
'Failed to rebuild tree for %s <%s>: %s', 'Failed to rebuild tree <%s> for %s: %s', tree_id, cls.__name__, e
self.__class__.__name__,
self.pk,
e,
) )
return False return False
@@ -1069,6 +1117,11 @@ class PathStringMixin(models.Model):
# Rebuild upper first, to ensure the lower nodes are updated correctly # Rebuild upper first, to ensure the lower nodes are updated correctly
super().save(*args, **kwargs) super().save(*args, **kwargs)
# Determine if a tree rebuild task was already triggered by this save
# (e.g. if the node was re-parented) - if so, the pathstring values
# for any lower nodes are updated by that task
rebuild_offloaded = getattr(self, '_tree_rebuild_offloaded', False)
# Ensure that the pathstring is correctly constructed # Ensure that the pathstring is correctly constructed
pathstring = self.construct_pathstring(refresh=True) pathstring = self.construct_pathstring(refresh=True)
@@ -1079,12 +1132,10 @@ class PathStringMixin(models.Model):
self.pathstring = pathstring self.pathstring = pathstring
super().save(*args, **kwargs) super().save(*args, **kwargs)
# Bulk-update any child nodes, if applicable # Update the pathstring values for any lower nodes,
lower_nodes = list( # by offloading the update to the background worker
self.get_descendants(include_self=False).values_list('pk', flat=True) if not rebuild_offloaded and self.get_descendant_count() > 0:
) self.__class__.offload_tree_rebuild([self.tree_id])
self.rebuild_lower_nodes(lower_nodes)
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
"""Custom delete method for PathStringMixin. """Custom delete method for PathStringMixin.
@@ -1102,9 +1153,7 @@ class PathStringMixin(models.Model):
) )
# Store the node ID values for lower nodes, before we delete this one # Store the node ID values for lower nodes, before we delete this one
lower_nodes = list( lower_nodes = self.get_lower_nodes()
self.get_descendants(include_self=False).values_list('pk', flat=True)
)
# Delete this node - after which we expect the tree structure will be updated # Delete this node - after which we expect the tree structure will be updated
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
@@ -1116,6 +1165,12 @@ class PathStringMixin(models.Model):
"""String representation of a category is the full path to that category.""" """String representation of a category is the full path to that category."""
return f'{self.pathstring} - {self.description}' return f'{self.pathstring} - {self.description}'
def get_lower_nodes(self) -> list[int]:
"""Return a list of all lower nodes in the tree."""
return list(
self.get_descendants(include_self=False).values_list('pk', flat=True)
)
def rebuild_lower_nodes(self, lower_nodes: list[int]): def rebuild_lower_nodes(self, lower_nodes: list[int]):
"""Rebuild the pathstring for lower nodes in the tree. """Rebuild the pathstring for lower nodes in the tree.
@@ -1136,6 +1191,52 @@ class PathStringMixin(models.Model):
if len(nodes_to_update) > 0: if len(nodes_to_update) > 0:
self.__class__.objects.bulk_update(nodes_to_update, ['pathstring']) self.__class__.objects.bulk_update(nodes_to_update, ['pathstring'])
@classmethod
def rebuild_tree_pathstring_values(cls, tree_ids) -> None:
"""Rebuild the 'pathstring' values for all nodes in the specified trees.
Each tree is processed in a single pass:
the pathstring for each node is constructed from its parent node,
and any changed values are written back in a single bulk update.
"""
tree_nodes = list(cls.objects.filter(tree_id__in=tree_ids))
node_map = {node.pk: node for node in tree_nodes}
# Cache of node ID -> list of path elements (from the top level down)
path_cache: dict[int, list[str]] = {}
def path_names(node) -> list[str]:
"""Construct the path (list of names) for a node, via its parent chain."""
if node.pk in path_cache:
return path_cache[node.pk]
names = [str(getattr(node, cls.PATH_FIELD, node.pk))]
if node.parent_id:
parent = node_map.get(node.parent_id)
if parent is None:
# Parent node exists outside the selected trees
parent = cls.objects.get(pk=node.parent_id)
node_map[node.parent_id] = parent
names = [*path_names(parent), *names]
path_cache[node.pk] = names
return names
nodes_to_update = []
for node in tree_nodes:
pathstring = InvenTree.helpers.constructPathString(path_names(node))
if pathstring != node.pathstring:
node.pathstring = pathstring
nodes_to_update.append(node)
if len(nodes_to_update) > 0:
cls.objects.bulk_update(nodes_to_update, ['pathstring'], batch_size=250)
def construct_pathstring(self, refresh: bool = False) -> str: def construct_pathstring(self, refresh: bool = False) -> str:
"""Construct the pathstring for this tree node. """Construct the pathstring for this tree node.
+34
View File
@@ -617,6 +617,40 @@ def scheduled_task(
return _task_wrapper return _task_wrapper
@tracer.start_as_current_span('rebuild_model_tree')
def rebuild_model_tree(model: str, tree_id: int) -> None:
"""Rebuild the tree structure (and pathstring values) for a tree model.
This task is offloaded to the background worker whenever nodes are
restructured (e.g. re-parented), to avoid expensive tree rebuild
operations blocking the calling thread.
Arguments:
model: Label of the model class to rebuild, e.g. 'stock.stocklocation'
tree_id: ID of the tree to rebuild
"""
from django.apps import apps
import InvenTree.models
try:
model_class = apps.get_model(model)
except (LookupError, ValueError):
logger.warning("rebuild_model_tree: Model '%s' does not exist", model)
return
if not issubclass(model_class, InvenTree.models.InvenTreeTree):
logger.warning("rebuild_model_tree: Model '%s' is not a tree model", model)
return
# Rebuild the tree structure, based on the parent-child relationships
model_class.rebuild_trees([tree_id])
# Rebuild the 'pathstring' values for the entire tree (if applicable)
if issubclass(model_class, InvenTree.models.PathStringMixin):
model_class.rebuild_tree_pathstring_values([tree_id])
@tracer.start_as_current_span('heartbeat') @tracer.start_as_current_span('heartbeat')
@scheduled_task(ScheduledTask.MINUTES, 1) @scheduled_task(ScheduledTask.MINUTES, 1)
def heartbeat(): def heartbeat():
+6
View File
@@ -2047,6 +2047,12 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
if quantity > item.quantity: if quantity > item.quantity:
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 # Split the allocated stock if there are more available than allocated
if item.quantity > quantity: if item.quantity > quantity:
item = item.splitStock(quantity, None, user, notes=notes) 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.line_1.refresh_from_db()
self.assertEqual(self.line_1.consumed, 8) 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): def test_complete_with_required_tests(self):
"""Test the prevention completion when a required test is missing feature.""" """Test the prevention completion when a required test is missing feature."""
# with required tests incompleted the save should fail # with required tests incompleted the save should fail
+1 -1
View File
@@ -10,7 +10,7 @@ import common.validators
class ParameterTemplateAdmin(admin.ModelAdmin): class ParameterTemplateAdmin(admin.ModelAdmin):
"""Admin interface for ParameterTemplate objects.""" """Admin interface for ParameterTemplate objects."""
list_display = ('name', 'description', 'model_type', 'units') list_display = ('name', 'description', 'model_type', 'units', 'unique')
search_fields = ('name', 'description') search_fields = ('name', 'description')
+1 -1
View File
@@ -877,7 +877,7 @@ class ParameterTemplateFilter(FilterSet):
"""Metaclass options.""" """Metaclass options."""
model = common.models.ParameterTemplate model = common.models.ParameterTemplate
fields = ['name', 'units', 'checkbox', 'enabled'] fields = ['name', 'units', 'checkbox', 'enabled', 'unique']
has_choices = rest_filters.BooleanFilter( has_choices = rest_filters.BooleanFilter(
method='filter_has_choices', label='Has Choice' 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 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 @staticmethod
def get_api_url() -> str: def get_api_url() -> str:
"""Return the API URL associated with the ParameterTemplate model.""" """Return the API URL associated with the ParameterTemplate model."""
@@ -2783,6 +2796,15 @@ class ParameterTemplate(
help_text=_('Is this parameter template enabled?'), 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( @receiver(
post_save, sender=ParameterTemplate, dispatch_uid='post_save_parameter_template' post_save, sender=ParameterTemplate, dispatch_uid='post_save_parameter_template'
@@ -2888,6 +2910,9 @@ class Parameter(
except ValidationError as e: except ValidationError as e:
raise ValidationError({'data': e.message}) raise ValidationError({'data': e.message})
# Validate the parameter data against any uniqueness requirements imposed by the template
self.validate_uniqueness()
if InvenTree.ready.isReadOnlyCommand(): if InvenTree.ready.isReadOnlyCommand():
# Skip plugin validation checks during read-only management commands # Skip plugin validation checks during read-only management commands
return return
@@ -2935,6 +2960,42 @@ class Parameter(
if math.isnan(self.data_numeric) or math.isinf(self.data_numeric): if math.isnan(self.data_numeric) or math.isinf(self.data_numeric):
self.data_numeric = None 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): def check_permission(self, permission, user):
"""Check if the user has the required permission for this parameter.""" """Check if the user has the required permission for this parameter."""
from InvenTree.models import InvenTreeParameterMixin from InvenTree.models import InvenTreeParameterMixin
+2 -3
View File
@@ -874,6 +874,7 @@ class ParameterTemplateSerializer(
'choices', 'choices',
'selectionlist', 'selectionlist',
'enabled', 'enabled',
'unique',
] ]
# Note: The choices are overridden at run-time on class initialization # Note: The choices are overridden at run-time on class initialization
@@ -946,9 +947,7 @@ class ParameterSerializer(
if not target_model_class.check_related_permission('change', user): if not target_model_class.check_related_permission('change', user):
raise PermissionDenied(permission_error_msg) raise PermissionDenied(permission_error_msg)
instance = super().save(**kwargs) instance = super().save(updated_by=user, **kwargs)
instance.updated_by = user
instance.save()
return instance return instance
@@ -951,6 +951,14 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False, 'default': False,
'validator': bool, 'validator': bool,
}, },
'TRANSFERORDER_EDIT_COMPLETED_ORDERS': {
'name': _('Edit Completed Transfer Orders'),
'description': _(
'Allow editing of transfer orders after they have been completed'
),
'default': False,
'validator': bool,
},
'SALESORDER_BLOCK_INCOMPLETE_ITEM_TESTS': { 'SALESORDER_BLOCK_INCOMPLETE_ITEM_TESTS': {
'name': _('Block Incomplete Item Tests'), 'name': _('Block Incomplete Item Tests'),
'description': _( 'description': _(
+268 -1
View File
@@ -2,9 +2,11 @@
import io import io
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.test.utils import override_settings
from django.urls import reverse from django.urls import reverse
from PIL import Image from PIL import Image
@@ -12,7 +14,8 @@ from taggit.models import Tag
import common.models import common.models
from common.models import SelectionList, SelectionListEntry from common.models import SelectionList, SelectionListEntry
from InvenTree.unit_test import InvenTreeAPITestCase from common.settings import set_global_setting
from InvenTree.unit_test import InvenTreeAPITestCase, findOffloadedEvent
class DataOutputAPITests(InvenTreeAPITestCase): class DataOutputAPITests(InvenTreeAPITestCase):
@@ -73,6 +76,7 @@ class ParameterAPITests(InvenTreeAPITestCase):
'model_type', 'model_type',
'selectionlist', 'selectionlist',
'enabled', 'enabled',
'unique',
]: ]:
self.assertIn( self.assertIn(
field, field,
@@ -567,6 +571,269 @@ class ParameterAPITests(InvenTreeAPITestCase):
common.models.Parameter.objects.filter(pk=parameter.pk).exists() common.models.Parameter.objects.filter(pk=parameter.pk).exists()
) )
@override_settings(
TESTING_TABLE_EVENTS=True,
PLUGIN_TESTING_EVENTS=True,
PLUGIN_TESTING_EVENTS_ASYNC=True,
)
def test_bulk_create_parameters(self):
"""Test bulk creation of parameters via the API.
Test that:
- The correct number of items are created
- Instance creation events are offloaded to the background worker
"""
from django_q.models import OrmQ
from part.models import Part
self.assignRole('part.add')
OrmQ.objects.all().delete()
set_global_setting('ENABLE_PLUGINS_EVENTS', True)
template = common.models.ParameterTemplate.objects.create(
name='Test Parameter',
description='A parameter template for testing bulk creation',
model_type=None,
)
# Generate a set of parts
parts = [
Part.objects.create(
name=f'Test Part {ii}', description='A part for testing'
)
for ii in range(50)
]
N = common.models.Parameter.objects.count()
# Bulk-create parameters
response = self.post(
reverse('api-parameter-list'),
data=[
{
'template': template.pk,
'model_type': 'part.part',
'model_id': part.pk,
'data': f'Test data {part.pk}',
}
for part in parts
],
benchmark=True,
max_query_count=500,
max_query_time=2.0,
)
self.assertEqual(len(response.data), 50)
# Check that the parameters have been created
self.assertEqual(common.models.Parameter.objects.count(), N + len(parts))
# We expect that 50 events have been offloaded to the background worker
self.assertGreaterEqual(OrmQ.objects.count(), len(parts))
# There should be a parameter for each part
for part in parts:
self.assertEqual(part.parameters.count(), 1)
parameter = part.parameters.first()
self.assertIsNotNone(parameter)
self.assertIsNotNone(parameter.updated)
self.assertIsNotNone(parameter.updated_by)
self.assertEqual(parameter.updated_by, self.user)
# Check that an associated event has been offloaded
self.assertIsNotNone(
findOffloadedEvent(
'part_partparameter.created', matching_kwargs={'id': parameter.pk}
),
f'No created event found for parameter {parameter.pk}',
)
# Check that an extra 'saved' event is *NOT* generated
self.assertIsNone(
findOffloadedEvent(
'part_partparameter.saved', matching_kwargs={'id': parameter.pk}
),
f'Unexpected saved event found for parameter {parameter.pk}',
)
set_global_setting('ENABLE_PLUGINS_EVENTS', False)
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): def test_parameter_annotation(self):
"""Test that we can annotate parameters against a queryset.""" """Test that we can annotate parameters against a queryset."""
from company.models import Company from company.models import Company
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+19 -6
View File
@@ -770,7 +770,7 @@ class PurchaseOrderLineItemDetail(
class PurchaseOrderExtraLineList( class PurchaseOrderExtraLineList(
GeneralExtraLineList, OutputOptionsMixin, ListCreateAPI GeneralExtraLineList, OutputOptionsMixin, ListCreateDestroyAPIView
): ):
"""API endpoint for accessing a list of PurchaseOrderExtraLine objects.""" """API endpoint for accessing a list of PurchaseOrderExtraLine objects."""
@@ -1062,7 +1062,10 @@ class SalesOrderLineItemOutputOptions(OutputConfiguration):
class SalesOrderLineItemList( class SalesOrderLineItemList(
SalesOrderLineItemMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI SalesOrderLineItemMixin,
DataExportViewMixin,
OutputOptionsMixin,
ListCreateDestroyAPIView,
): ):
"""API endpoint for accessing a list of SalesOrderLineItem objects.""" """API endpoint for accessing a list of SalesOrderLineItem objects."""
@@ -1106,7 +1109,9 @@ class SalesOrderLineItemDetail(
output_options = SalesOrderLineItemOutputOptions output_options = SalesOrderLineItemOutputOptions
class SalesOrderExtraLineList(GeneralExtraLineList, OutputOptionsMixin, ListCreateAPI): class SalesOrderExtraLineList(
GeneralExtraLineList, OutputOptionsMixin, ListCreateDestroyAPIView
):
"""API endpoint for accessing a list of SalesOrderExtraLine objects.""" """API endpoint for accessing a list of SalesOrderExtraLine objects."""
queryset = models.SalesOrderExtraLine.objects.all() queryset = models.SalesOrderExtraLine.objects.all()
@@ -1776,7 +1781,10 @@ class ReturnOrderLineItemOutputOptions(OutputConfiguration):
class ReturnOrderLineItemList( class ReturnOrderLineItemList(
ReturnOrderLineItemMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI ReturnOrderLineItemMixin,
DataExportViewMixin,
OutputOptionsMixin,
ListCreateDestroyAPIView,
): ):
"""API endpoint for accessing a list of ReturnOrderLineItemList objects.""" """API endpoint for accessing a list of ReturnOrderLineItemList objects."""
@@ -1819,7 +1827,9 @@ class ReturnOrderLineItemDetail(
output_options = ReturnOrderLineItemOutputOptions output_options = ReturnOrderLineItemOutputOptions
class ReturnOrderExtraLineList(GeneralExtraLineList, OutputOptionsMixin, ListCreateAPI): class ReturnOrderExtraLineList(
GeneralExtraLineList, OutputOptionsMixin, ListCreateDestroyAPIView
):
"""API endpoint for accessing a list of ReturnOrderExtraLine objects.""" """API endpoint for accessing a list of ReturnOrderExtraLine objects."""
queryset = models.ReturnOrderExtraLine.objects.all() queryset = models.ReturnOrderExtraLine.objects.all()
@@ -2314,7 +2324,10 @@ class TransferOrderLineItemOutputOptions(OutputConfiguration):
class TransferOrderLineItemList( class TransferOrderLineItemList(
TransferOrderLineItemMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI TransferOrderLineItemMixin,
DataExportViewMixin,
OutputOptionsMixin,
ListCreateDestroyAPIView,
): ):
"""API endpoint for accessing a list of TransferOrderLineItem objects.""" """API endpoint for accessing a list of TransferOrderLineItem objects."""
+98 -1
View File
@@ -1633,6 +1633,97 @@ class SalesOrder(TotalPriceMixin, Order):
SalesOrderAllocation.objects.bulk_create(new_allocations, batch_size=250) SalesOrderAllocation.objects.bulk_create(new_allocations, batch_size=250)
@transaction.atomic
def allocate_serial_numbers(
self,
line_item: 'SalesOrderLineItem',
quantity: int,
serial_numbers: str,
shipment: Optional['SalesOrderShipment'] = None,
) -> list['SalesOrderAllocation']:
"""Allocate stock items against this SalesOrder, by serial number.
Arguments:
line_item: The SalesOrderLineItem to allocate against
quantity: The number of serial numbers expected
serial_numbers: A string of serial numbers to allocate (e.g. "1,2,3-5")
shipment: Optional shipment to assign the allocations to
Raises:
ValidationError: If the line item does not belong to this order,
the serial numbers cannot be parsed, or any of the requested
serial numbers do not exist or are unavailable for allocation.
"""
if line_item.order != self:
raise ValidationError(_('Line item is not associated with this order'))
part = line_item.part
serials = InvenTree.helpers.extract_serial_numbers(
serial_numbers, quantity, part.get_latest_serial_number(), part=part
)
serials_not_exist = set()
serials_unavailable = set()
stock_items_to_allocate = []
for serial in serials:
serial = str(serial).strip()
items = stock.models.StockItem.objects.filter(
part=part, serial=serial, quantity=1
)
if not items.exists():
serials_not_exist.add(serial)
continue
stock_item = items[0]
if get_global_setting('SALESORDER_BLOCK_INCOMPLETE_ITEM_TESTS'):
if (
stock_item.hasRequiredTests()
and not stock_item.passedAllRequiredTests()
):
serials_unavailable.add(serial)
continue
if not stock_item.in_stock:
serials_unavailable.add(serial)
continue
if stock_item.unallocated_quantity() < 1:
serials_unavailable.add(serial)
continue
# At this point, the serial number is valid, and can be added to the list
stock_items_to_allocate.append(stock_item)
if len(serials_not_exist) > 0:
error_msg = _('No match found for the following serial numbers')
error_msg += ': '
error_msg += ','.join(sorted(serials_not_exist))
raise ValidationError({'serial_numbers': error_msg})
if len(serials_unavailable) > 0:
error_msg = _('The following serial numbers are unavailable')
error_msg += ': '
error_msg += ','.join(sorted(serials_unavailable))
raise ValidationError({'serial_numbers': error_msg})
allocations = [
SalesOrderAllocation(
line=line_item, item=stock_item, quantity=1, shipment=shipment
)
for stock_item in stock_items_to_allocate
]
SalesOrderAllocation.objects.bulk_create(allocations, batch_size=250)
return allocations
def is_completed(self) -> bool: def is_completed(self) -> bool:
"""Check if this order is "shipped" (all line items delivered). """Check if this order is "shipped" (all line items delivered).
@@ -3098,6 +3189,12 @@ class ReturnOrder(TotalPriceMixin, Order):
def _action_complete(self, *args, **kwargs): def _action_complete(self, *args, **kwargs):
"""Complete this ReturnOrder (if not already completed).""" """Complete this ReturnOrder (if not already completed)."""
# Lock this order against concurrent completion, and re-read the status
# from the database. Without this, two simultaneous completion requests
# can both observe status=IN_PROGRESS, and each would run the completion
# side effects (duplicate events and notifications).
self.status = ReturnOrder.objects.select_for_update().get(pk=self.pk).status
if self.status == ReturnOrderStatus.IN_PROGRESS.value: if self.status == ReturnOrderStatus.IN_PROGRESS.value:
self.status = ReturnOrderStatus.COMPLETE.value self.status = ReturnOrderStatus.COMPLETE.value
self.complete_date = InvenTree.helpers.current_date() self.complete_date = InvenTree.helpers.current_date()
@@ -3381,7 +3478,7 @@ class TransferOrder(Order):
REFERENCE_PATTERN_SETTING = 'TRANSFERORDER_REFERENCE_PATTERN' REFERENCE_PATTERN_SETTING = 'TRANSFERORDER_REFERENCE_PATTERN'
REQUIRE_RESPONSIBLE_SETTING = 'TRANSFERORDER_REQUIRE_RESPONSIBLE' REQUIRE_RESPONSIBLE_SETTING = 'TRANSFERORDER_REQUIRE_RESPONSIBLE'
STATUS_CLASS = TransferOrderStatus STATUS_CLASS = TransferOrderStatus
# UNLOCK_SETTING = 'TRANSFERORDER_EDIT_COMPLETED_ORDERS' UNLOCK_SETTING = 'TRANSFERORDER_EDIT_COMPLETED_ORDERS'
class Meta: class Meta:
"""Model meta options.""" """Model meta options."""
+36 -91
View File
@@ -1010,6 +1010,13 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
# Ensure barcodes are unique # Ensure barcodes are unique
unique_barcodes = set() unique_barcodes = set()
# Ensure serial numbers are unique across all line items in this request
# (each line is only validated against the *database* individually)
unique_serials = set()
serials_globally_unique = get_global_setting(
'SERIAL_NUMBER_GLOBALLY_UNIQUE', False
)
# Check if the location is not specified for any particular item # Check if the location is not specified for any particular item
for item in items: for item in items:
line = item['line_item'] line = item['line_item']
@@ -1035,6 +1042,25 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
else: else:
unique_barcodes.add(barcode) unique_barcodes.add(barcode)
if serials := item.get('serials'):
if line.part:
# Scope uniqueness the same way as the database check:
# per part tree, or globally (if so configured)
for serial in serials:
key = (
str(serial)
if serials_globally_unique
else (line.part.part.tree_id, str(serial))
)
if key in unique_serials:
raise ValidationError(
_('Supplied serial numbers must be unique')
+ f': {serial}'
)
unique_serials.add(key)
return data return data
def save(self) -> list[stock.models.StockItem]: def save(self) -> list[stock.models.StockItem]:
@@ -1871,104 +1897,23 @@ class SalesOrderSerialAllocationSerializer(serializers.Serializer):
return shipment return shipment
def validate(self, data): def save(self):
"""Validation for the serializer. """Allocate stock items against the sales order, by serial number."""
data = self.validated_data
- Ensure the serial_numbers and quantity fields match
- Check that all serial numbers exist
- Check that the serial numbers are not yet allocated
"""
data = super().validate(data)
sales_order = self.context['order']
line_item = data['line_item'] line_item = data['line_item']
quantity = data['quantity'] quantity = data['quantity']
serial_numbers = data['serial_numbers'] serial_numbers = data['serial_numbers']
part = line_item.part
try:
data['serials'] = extract_serial_numbers(
serial_numbers, quantity, part.get_latest_serial_number(), part=part
)
except DjangoValidationError as e:
raise ValidationError({'serial_numbers': e.messages})
serials_not_exist = set()
serials_unavailable = set()
stock_items_to_allocate = []
for serial in data['serials']:
serial = str(serial).strip()
items = stock.models.StockItem.objects.filter(
part=part, serial=serial, quantity=1
)
if not items.exists():
serials_not_exist.add(str(serial))
continue
stock_item = items[0]
if get_global_setting('SALESORDER_BLOCK_INCOMPLETE_ITEM_TESTS'):
if (
stock_item.hasRequiredTests()
and not stock_item.passedAllRequiredTests()
):
serials_unavailable.add(str(serial))
continue
if not stock_item.in_stock:
serials_unavailable.add(str(serial))
continue
if stock_item.unallocated_quantity() < 1:
serials_unavailable.add(str(serial))
continue
# At this point, the serial number is valid, and can be added to the list
stock_items_to_allocate.append(stock_item)
if len(serials_not_exist) > 0:
error_msg = _('No match found for the following serial numbers')
error_msg += ': '
error_msg += ','.join(sorted(serials_not_exist))
raise ValidationError({'serial_numbers': error_msg})
if len(serials_unavailable) > 0:
error_msg = _('The following serial numbers are unavailable')
error_msg += ': '
error_msg += ','.join(sorted(serials_unavailable))
raise ValidationError({'serial_numbers': error_msg})
data['stock_items'] = stock_items_to_allocate
return data
def save(self):
"""Allocate stock items against the sales order."""
data = self.validated_data
line_item = data['line_item']
stock_items = data['stock_items']
shipment = data.get('shipment', None) shipment = data.get('shipment', None)
allocations = [] try:
return sales_order.allocate_serial_numbers(
for stock_item in stock_items: line_item, quantity, serial_numbers, shipment=shipment
# Create a new SalesOrderAllocation
allocations.append(
order.models.SalesOrderAllocation(
line=line_item, item=stock_item, quantity=1, shipment=shipment
)
)
with transaction.atomic():
order.models.SalesOrderAllocation.objects.bulk_create(
allocations, batch_size=250
) )
except (ValidationError, DjangoValidationError) as exc:
# Catch model errors and re-throw as DRF errors
raise ValidationError(detail=serializers.as_serializer_error(exc))
class SalesOrderShipmentAllocationSerializer(serializers.Serializer): class SalesOrderShipmentAllocationSerializer(serializers.Serializer):
+430 -2
View File
@@ -5,6 +5,7 @@ import io
import json import json
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from typing import Optional from typing import Optional
from unittest import mock
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@@ -899,16 +900,46 @@ class PurchaseOrderLineItemTest(OrderTest):
"""Test that we can bulk delete multiple PurchaseOrderLineItems via the API.""" """Test that we can bulk delete multiple PurchaseOrderLineItems via the API."""
n = models.PurchaseOrderLineItem.objects.count() n = models.PurchaseOrderLineItem.objects.count()
self.assignRole('purchase_order.delete')
url = reverse('api-po-line-list') url = reverse('api-po-line-list')
# Deletion should fail without the correct role
self.delete(url, {'items': [1, 2]}, expected_code=403)
self.assignRole('purchase_order.delete')
# Try to delete a set of line items via their IDs # Try to delete a set of line items via their IDs
self.delete(url, {'items': [1, 2]}, expected_code=200) self.delete(url, {'items': [1, 2]}, expected_code=200)
# We should have 2 less PurchaseOrderLineItems after deleting them # We should have 2 less PurchaseOrderLineItems after deleting them
self.assertEqual(models.PurchaseOrderLineItem.objects.count(), n - 2) self.assertEqual(models.PurchaseOrderLineItem.objects.count(), n - 2)
def test_po_extra_line_bulk_delete(self):
"""Test that we can bulk delete multiple PurchaseOrderExtraLine items via the API."""
po = models.PurchaseOrder.objects.get(pk=1)
models.PurchaseOrderExtraLine.objects.bulk_create([
models.PurchaseOrderExtraLine(
order=po, quantity=idx + 1, reference=f'Extra line {idx}'
)
for idx in range(3)
])
n = models.PurchaseOrderExtraLine.objects.count()
items = list(
models.PurchaseOrderExtraLine.objects.values_list('pk', flat=True)[:2]
)
url = reverse('api-po-extra-line-list')
# Deletion should fail without the correct role
self.delete(url, {'items': items}, expected_code=403)
self.assignRole('purchase_order.delete')
self.delete(url, {'items': items}, expected_code=200)
self.assertEqual(models.PurchaseOrderExtraLine.objects.count(), n - 2)
def test_po_line_merge_pricing(self): def test_po_line_merge_pricing(self):
"""Test that we can create a new PurchaseOrderLineItem via the API.""" """Test that we can create a new PurchaseOrderLineItem via the API."""
self.assignRole('purchase_order.add') self.assignRole('purchase_order.add')
@@ -1368,6 +1399,43 @@ class PurchaseOrderReceiveTest(OrderTest):
self.assertEqual(item.quantity, 10) self.assertEqual(item.quantity, 10)
self.assertEqual(item.batch, 'B-xyz-789') self.assertEqual(item.batch, 'B-xyz-789')
def test_duplicate_serial_numbers_across_items(self):
"""Duplicate serial numbers across line items in a single request are rejected.
Regression test: each line's serials used to be validated against the
database only, so two lines could claim the same serial number and create
duplicate serialized stock items.
"""
data = {
'items': [
{'line_item': 1, 'quantity': 3, 'serial_numbers': '100-102'},
{'line_item': 1, 'quantity': 3, 'serial_numbers': '102-104'},
],
'location': 1,
}
# Serial 102 is claimed by both entries - request must be rejected
response = self.post(self.url, data, expected_code=400)
self.assertIn('Supplied serial numbers must be unique', str(response.data))
# No new stock items have been created
self.assertEqual(self.n, StockItem.objects.count())
# Non-overlapping serial numbers are accepted
data['items'][1]['serial_numbers'] = '103-105'
self.post(self.url, data, expected_code=201, max_query_count=250)
self.assertEqual(self.n + 6, StockItem.objects.count())
for serial in range(100, 106):
self.assertEqual(
StockItem.objects.filter(serial=str(serial)).count(),
1,
f'Expected exactly one stock item with serial {serial}',
)
def test_receive_large_quantity(self): def test_receive_large_quantity(self):
"""Test receipt of a large number of items.""" """Test receipt of a large number of items."""
from stock.status_codes import StockStatus from stock.status_codes import StockStatus
@@ -2207,6 +2275,49 @@ class SalesOrderLineItemTest(OrderTest):
self.filter({'allocated': 'true'}, 1) self.filter({'allocated': 'true'}, 1)
self.filter({'allocated': 'false'}, n - 1) self.filter({'allocated': 'false'}, n - 1)
def test_so_line_bulk_delete(self):
"""Test that we can bulk delete multiple SalesOrderLineItems via the API."""
n = models.SalesOrderLineItem.objects.count()
items = list(models.SalesOrderLineItem.objects.values_list('pk', flat=True)[:2])
# Deletion should fail without the correct role
self.delete(self.url, {'items': items}, expected_code=403)
self.assignRole('sales_order.delete')
self.delete(self.url, {'items': items}, expected_code=200)
# We should have 2 less SalesOrderLineItems after deleting them
self.assertEqual(models.SalesOrderLineItem.objects.count(), n - 2)
def test_so_extra_line_bulk_delete(self):
"""Test that we can bulk delete multiple SalesOrderExtraLine items via the API."""
so = models.SalesOrder.objects.first()
models.SalesOrderExtraLine.objects.bulk_create([
models.SalesOrderExtraLine(
order=so, quantity=idx + 1, reference=f'Extra line {idx}'
)
for idx in range(3)
])
n = models.SalesOrderExtraLine.objects.count()
items = list(
models.SalesOrderExtraLine.objects.values_list('pk', flat=True)[:2]
)
url = reverse('api-so-extra-line-list')
# Deletion should fail without the correct role
self.delete(url, {'items': items}, expected_code=403)
self.assignRole('sales_order.delete')
self.delete(url, {'items': items}, expected_code=200)
self.assertEqual(models.SalesOrderExtraLine.objects.count(), n - 2)
def test_so_line_allocated_filters(self): def test_so_line_allocated_filters(self):
"""Test filtering by allocation status for a SalesOrderLineItem.""" """Test filtering by allocation status for a SalesOrderLineItem."""
self.assignRole('sales_order.add') self.assignRole('sales_order.add')
@@ -2698,6 +2809,184 @@ class SalesOrderAllocateTest(OrderTest):
response = self.post(self.url, data, expected_code=201) response = self.post(self.url, data, expected_code=201)
class SalesOrderAllocateSerialsTest(OrderTest):
"""Unit tests for allocating stock items against a SalesOrder, by serial number."""
@classmethod
def setUpTestData(cls):
"""Init routine for this unit test class."""
super().setUpTestData()
def setUp(self):
"""Init routines for this unit testing class."""
super().setUp()
self.assignRole('sales_order.add')
self.url = reverse('api-so-allocate-serials', kwargs={'pk': 1})
self.order = models.SalesOrder.objects.get(pk=1)
self.part = Part.objects.create(
name='Serial Allocation Part',
salable=True,
trackable=True,
description='A trackable part for serial allocation tests',
)
self.line = models.SalesOrderLineItem.objects.create(
order=self.order, part=self.part, quantity=5
)
# Create some serialized stock items for this part
self.stock_items = [
StockItem.objects.create(part=self.part, quantity=1, serial=str(n))
for n in range(1, 6)
]
self.shipment = models.SalesOrderShipment.objects.create(order=self.order)
def test_allocate(self):
"""Test that we can allocate stock items to a SalesOrder line, by serial number."""
self.assertEqual(self.order.stock_allocations.count(), 0)
data = {'line_item': self.line.pk, 'quantity': 3, 'serial_numbers': '1,2,3'}
self.post(self.url, data, expected_code=201)
self.assertEqual(self.order.stock_allocations.count(), 3)
allocated_serials = {
allocation.item.serial for allocation in self.order.stock_allocations.all()
}
self.assertEqual(allocated_serials, {'1', '2', '3'})
for allocation in self.order.stock_allocations.all():
self.assertEqual(allocation.quantity, 1)
self.assertIsNone(allocation.shipment)
def test_allocate_with_shipment(self):
"""Test that allocations are correctly assigned to a provided shipment."""
data = {
'line_item': self.line.pk,
'quantity': 2,
'serial_numbers': '4,5',
'shipment': self.shipment.pk,
}
self.post(self.url, data, expected_code=201)
for allocation in self.order.stock_allocations.all():
self.assertEqual(allocation.shipment, self.shipment)
def test_invalid_line_item(self):
"""Test that a line item belonging to a different order is rejected."""
other_order = models.SalesOrder.objects.exclude(pk=self.order.pk).first()
other_line = models.SalesOrderLineItem.objects.create(
order=other_order, part=self.part, quantity=5
)
data = {'line_item': other_line.pk, 'quantity': 1, 'serial_numbers': '1'}
response = self.post(self.url, data, expected_code=400)
self.assertIn('Line item is not associated with this order', str(response.data))
self.assertEqual(self.order.stock_allocations.count(), 0)
def test_shipment_already_shipped(self):
"""Test that a shipment which has already been shipped is rejected."""
self.shipment.shipment_date = date.today()
self.shipment.save()
data = {
'line_item': self.line.pk,
'quantity': 1,
'serial_numbers': '1',
'shipment': self.shipment.pk,
}
response = self.post(self.url, data, expected_code=400)
self.assertIn('Shipment has already been shipped', str(response.data))
self.assertEqual(self.order.stock_allocations.count(), 0)
def test_shipment_wrong_order(self):
"""Test that a shipment belonging to a different order is rejected."""
other_order = models.SalesOrder.objects.exclude(pk=self.order.pk).first()
other_shipment = models.SalesOrderShipment.objects.create(order=other_order)
data = {
'line_item': self.line.pk,
'quantity': 1,
'serial_numbers': '1',
'shipment': other_shipment.pk,
}
response = self.post(self.url, data, expected_code=400)
self.assertIn('Shipment is not associated with this order', str(response.data))
self.assertEqual(self.order.stock_allocations.count(), 0)
def test_serial_not_exist(self):
"""Test that non-existent serial numbers are rejected."""
data = {'line_item': self.line.pk, 'quantity': 1, 'serial_numbers': '999'}
response = self.post(self.url, data, expected_code=400)
self.assertIn(
'No match found for the following serial numbers', str(response.data)
)
self.assertIn('999', str(response.data))
self.assertEqual(self.order.stock_allocations.count(), 0)
def test_serial_unavailable(self):
"""Test that an already-allocated serial number is rejected as unavailable."""
# Fully allocate stock item with serial '1' against some other line
models.SalesOrderAllocation.objects.create(
line=self.line, item=self.stock_items[0], quantity=1
)
data = {'line_item': self.line.pk, 'quantity': 1, 'serial_numbers': '1'}
response = self.post(self.url, data, expected_code=400)
self.assertIn(
'The following serial numbers are unavailable', str(response.data)
)
self.assertIn('1', str(response.data))
# No *additional* allocation should have been created
self.assertEqual(self.order.stock_allocations.count(), 1)
def test_block_on_required_tests(self):
"""Test the SALESORDER_BLOCK_INCOMPLETE_ITEM_TESTS setting."""
from part.models import PartTestTemplate
self.part.testable = True
self.part.save()
PartTestTemplate.objects.create(
part=self.part, test_name='A required test', required=True
)
data = {'line_item': self.line.pk, 'quantity': 1, 'serial_numbers': '1'}
set_global_setting('SALESORDER_BLOCK_INCOMPLETE_ITEM_TESTS', True)
response = self.post(self.url, data, expected_code=400)
self.assertIn(
'The following serial numbers are unavailable', str(response.data)
)
set_global_setting('SALESORDER_BLOCK_INCOMPLETE_ITEM_TESTS', False)
self.post(self.url, data, expected_code=201)
class ReturnOrderTests(InvenTreeAPITestCase): class ReturnOrderTests(InvenTreeAPITestCase):
"""Unit tests for ReturnOrder API endpoints.""" """Unit tests for ReturnOrder API endpoints."""
@@ -3066,6 +3355,41 @@ class ReturnOrderTests(InvenTreeAPITestCase):
stock_item.refresh_from_db() stock_item.refresh_from_db()
self.assertEqual(stock_item.quantity, 6) self.assertEqual(stock_item.quantity, 6)
def test_complete_stale_instance_is_noop(self):
"""A second completion attempt with a stale instance must be a no-op.
Regression test: _action_complete() checked 'status' on the caller's
(potentially stale) instance, so two concurrent completion requests
could both run the completion side effects (duplicate COMPLETED events).
The status is now re-read (under lock) from the database before the check.
"""
company = Company.objects.get(pk=4)
rma = models.ReturnOrder.objects.create(
customer=company, description='A return order'
)
rma.issue_order()
# Two "concurrent" requests each hold their own instance of the order
order_a = models.ReturnOrder.objects.get(pk=rma.pk)
order_b = models.ReturnOrder.objects.get(pk=rma.pk)
order_a.complete_order()
rma.refresh_from_db()
self.assertEqual(rma.status, ReturnOrderStatus.COMPLETE.value)
# The second (stale) instance still believes the order is IN_PROGRESS -
# completion must be skipped based on the database state
self.assertEqual(order_b.status, ReturnOrderStatus.IN_PROGRESS.value)
with mock.patch('order.models.trigger_event') as trigger:
order_b.complete_order()
trigger.assert_not_called()
rma.refresh_from_db()
self.assertEqual(rma.status, ReturnOrderStatus.COMPLETE.value)
def test_ro_calendar(self): def test_ro_calendar(self):
"""Test the calendar export endpoint.""" """Test the calendar export endpoint."""
# Full test is in test_po_calendar. Since these use the same backend, test only # Full test is in test_po_calendar. Since these use the same backend, test only
@@ -3207,6 +3531,53 @@ class ReturnOrderLineItemTests(InvenTreeAPITestCase):
line = models.ReturnOrderLineItem.objects.get(pk=1) line = models.ReturnOrderLineItem.objects.get(pk=1)
self.assertEqual(float(line.price.amount), 15.75) self.assertEqual(float(line.price.amount), 15.75)
def test_bulk_delete(self):
"""Test that we can bulk delete multiple ReturnOrderLineItems via the API."""
n = models.ReturnOrderLineItem.objects.count()
self.assertGreater(n, 0)
items = list(
models.ReturnOrderLineItem.objects.values_list('pk', flat=True)[:1]
)
url = reverse('api-return-order-line-list')
# Deletion should fail without the correct role
self.delete(url, {'items': items}, expected_code=403)
self.assignRole('return_order.delete')
self.delete(url, {'items': items}, expected_code=200)
self.assertEqual(models.ReturnOrderLineItem.objects.count(), n - 1)
def test_extra_line_bulk_delete(self):
"""Test that we can bulk delete multiple ReturnOrderExtraLine items via the API."""
ro = models.ReturnOrder.objects.first()
models.ReturnOrderExtraLine.objects.bulk_create([
models.ReturnOrderExtraLine(
order=ro, quantity=idx + 1, reference=f'Extra line {idx}'
)
for idx in range(3)
])
n = models.ReturnOrderExtraLine.objects.count()
items = list(
models.ReturnOrderExtraLine.objects.values_list('pk', flat=True)[:2]
)
url = reverse('api-return-order-extra-line-list')
# Deletion should fail without the correct role
self.delete(url, {'items': items}, expected_code=403)
self.assignRole('return_order.delete')
self.delete(url, {'items': items}, expected_code=200)
self.assertEqual(models.ReturnOrderExtraLine.objects.count(), n - 2)
class TransferOrderTest(OrderTest): class TransferOrderTest(OrderTest):
"""Tests for the TransferOrder API.""" """Tests for the TransferOrder API."""
@@ -3847,6 +4218,63 @@ class TransferOrderLineItemTest(OrderTest):
self.filter({'allocated': 'true'}, 2) self.filter({'allocated': 'true'}, 2)
self.filter({'allocated': 'false'}, n - 2) self.filter({'allocated': 'false'}, n - 2)
def test_transfer_order_line_bulk_delete(self):
"""Test that we can bulk delete multiple TransferOrderLineItems via the API."""
n = models.TransferOrderLineItem.objects.count()
# Select lines from orders which are not completed (and thus not locked)
items = list(
models.TransferOrderLineItem.objects.exclude(
order__status__in=TransferOrderStatusGroups.COMPLETE
).values_list('pk', flat=True)[:2]
)
# Deletion should fail without the correct role
self.delete(self.url, {'items': items}, expected_code=403)
self.assignRole('transfer_order.delete')
self.delete(self.url, {'items': items}, expected_code=200)
# We should have 2 less TransferOrderLineItems after deleting them
self.assertEqual(models.TransferOrderLineItem.objects.count(), n - 2)
def test_completed_order_locked(self):
"""Test that line items cannot be deleted from a completed TransferOrder."""
self.assignRole('transfer_order.delete')
set_global_setting(models.TransferOrder.UNLOCK_SETTING, False)
order = models.TransferOrder.objects.filter(
status=TransferOrderStatus.PENDING.value, lines__isnull=False
).first()
assert order
# Mark the order as complete
order.status = TransferOrderStatus.COMPLETE.value
order.save()
n = order.lines.count()
self.assertGreater(n, 1)
line = order.lines.first()
detail_url = reverse('api-transfer-order-line-detail', kwargs={'pk': line.pk})
# Single deletion of a line item should fail
self.delete(detail_url, expected_code=400)
# Bulk deletion should also fail (and roll back atomically)
items = list(order.lines.values_list('pk', flat=True))
self.delete(self.url, {'items': items}, expected_code=400)
self.assertEqual(order.lines.count(), n)
# Unlocking completed orders should allow deletion again
set_global_setting(models.TransferOrder.UNLOCK_SETTING, True)
self.delete(detail_url, expected_code=204)
self.assertEqual(order.lines.count(), n - 1)
def test_transfer_order_line_allocated_filters(self): def test_transfer_order_line_allocated_filters(self):
"""Test filtering by allocation status for a TransferOrderLineItem.""" """Test filtering by allocation status for a TransferOrderLineItem."""
self.assignRole('transfer_order.add') self.assignRole('transfer_order.add')
@@ -6,13 +6,18 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Sum from django.db.models import Sum
from django.urls import reverse
import order.tasks import order.tasks
from common.models import InvenTreeSetting, NotificationMessage from common.models import InvenTreeSetting, NotificationMessage
from common.settings import set_global_setting from common.settings import set_global_setting
from company.models import Address, Company from company.models import Address, Company
from InvenTree import status_codes as status from InvenTree import status_codes as status
from InvenTree.unit_test import InvenTreeTestCase, addUserPermission from InvenTree.unit_test import (
InvenTreeAPITestCase,
InvenTreeTestCase,
addUserPermission,
)
from order.models import ( from order.models import (
SalesOrder, SalesOrder,
SalesOrderAllocation, SalesOrderAllocation,
@@ -25,14 +30,17 @@ from stock.models import StockItem, StockItemTracking, StockLocation
from users.models import Owner from users.models import Owner
class SalesOrderTest(InvenTreeTestCase): class SalesOrderTest(InvenTreeAPITestCase):
"""Run tests to ensure that the SalesOrder model is working correctly.""" """Run tests to ensure that the SalesOrder model is working correctly."""
fixtures = ['company', 'users'] fixtures = ['company', 'users']
roles = ['sales_order.add']
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
"""Initial setup for this set of unit tests.""" """Initial setup for this set of unit tests."""
super().setUpTestData()
# Create a Company to ship the goods to # Create a Company to ship the goods to
cls.customer = Company.objects.create( cls.customer = Company.objects.create(
name='ABC Co', description='My customer', is_customer=True name='ABC Co', description='My customer', is_customer=True
@@ -455,8 +463,18 @@ class SalesOrderTest(InvenTreeTestCase):
self.assertFalse(shipment.is_complete()) self.assertFalse(shipment.is_complete())
self.assertTrue(shipment.check_can_complete(raise_error=False)) self.assertTrue(shipment.check_can_complete(raise_error=False))
# Complete the shipment # Complete the shipment via the API
shipment.complete_shipment(None) self.assignRole('sales_order.add')
url = reverse('api-so-shipment-ship', kwargs={'pk': shipment.pk})
response = self.post(
url,
expected_code=200,
benchmark=True,
max_query_time=100,
max_query_count=10000,
)
self.assertEqual(response.status_code, 200)
shipment.refresh_from_db() shipment.refresh_from_db()
self.assertIsNotNone(shipment.shipment_date) self.assertIsNotNone(shipment.shipment_date)
+8
View File
@@ -2348,6 +2348,14 @@ class Part(
if category_template.template.pk in template_ids: if category_template.template.pk in template_ids:
continue 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) template_ids.add(category_template.template.pk)
parameters.append( parameters.append(
+5 -4
View File
@@ -1038,12 +1038,13 @@ class PartSerializer(
initial_supplier = validated_data.pop('initial_supplier', None) initial_supplier = validated_data.pop('initial_supplier', None)
copy_category_parameters = validated_data.pop('copy_category_parameters', False) copy_category_parameters = validated_data.pop('copy_category_parameters', False)
instance = super().create(validated_data) # Additional data to apply to the serializer
extra_data = {}
# Save user information
if request := self.context.get('request'): if request := self.context.get('request'):
instance.creation_user = request.user extra_data['creation_user'] = request.user
instance.save()
instance = super().create({**validated_data, **extra_data})
# Copy data from original Part # Copy data from original Part
if duplicate: if duplicate:
+188 -2
View File
@@ -7,7 +7,7 @@ from random import randint
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import connection from django.db import connection
from django.test.utils import CaptureQueriesContext from django.test.utils import CaptureQueriesContext, override_settings
from django.urls import reverse from django.urls import reverse
import pytest import pytest
@@ -19,9 +19,15 @@ import company.models
import order.models import order.models
from build.status_codes import BuildStatus from build.status_codes import BuildStatus
from common.models import InvenTreeSetting, ParameterTemplate from common.models import InvenTreeSetting, ParameterTemplate
from common.settings import set_global_setting
from company.models import Company, SupplierPart from company.models import Company, SupplierPart
from InvenTree.config import get_testfolder_dir from InvenTree.config import get_testfolder_dir
from InvenTree.unit_test import InvenTreeAPIPerformanceTestCase, InvenTreeAPITestCase from InvenTree.unit_test import (
InvenTreeAPIPerformanceTestCase,
InvenTreeAPITestCase,
findOffloadedEvent,
findOffloadedTask,
)
from order.status_codes import PurchaseOrderStatusGroups from order.status_codes import PurchaseOrderStatusGroups
from part.models import ( from part.models import (
BomItem, BomItem,
@@ -104,6 +110,72 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
'part_category.delete', 'part_category.delete',
] ]
def test_bulk_set_parent(self):
"""Test that bulk re-parenting of categories correctly rebuilds the tree.
Re-parenting multiple categories in a single 'bulk update' API call must
leave the tree structure and the 'pathstring' values fully consistent.
Ref: https://github.com/inventree/InvenTree/issues/12394
"""
url = reverse('api-part-category-list')
parent_a = PartCategory.objects.create(name='Parent A')
parent_b = PartCategory.objects.create(name='Parent B')
# Create a number of subcategories under 'Parent A',
# some of which have their own child categories
categories = []
for i in range(50):
category = PartCategory.objects.create(name=f'Cat{i:02d}', parent=parent_a)
categories.append(category)
if i % 5 == 0:
child = PartCategory.objects.create(
name=f'Cat{i:02d}-child', parent=category
)
PartCategory.objects.create(name=f'Cat{i:02d}-grandchild', parent=child)
# Move all subcategories to 'Parent B' in a single bulk update.
# The query count must scale linearly with the number of items.
# Note: when the background worker is not running (e.g. in tests), each
# item save runs the (de-duplicated) tree rebuild task synchronously
self.patch(
url,
{'items': [category.pk for category in categories], 'parent': parent_b.pk},
expected_code=200,
max_query_count=60 * len(categories),
max_query_time=10, # Note: in production this is offloaded to the background worker
)
for category in categories:
category.refresh_from_db()
self.assertEqual(category.parent, parent_b)
parent_a.refresh_from_db()
parent_b.refresh_from_db()
# Check *all* categories in the affected trees
# (fixture data outside these trees does not have pathstring values set)
affected_trees = PartCategory.objects.filter(
tree_id__in=[parent_a.tree_id, parent_b.tree_id]
)
for category in affected_trees:
# The stored pathstring must match the 'parent' chain for the node
chain = []
node = category
while node is not None:
chain.insert(0, node)
node = node.parent
self.assertEqual(category.pathstring, '/'.join(node.name for node in chain))
# The MPTT tree data must match the 'parent' chain, too
self.assertEqual(list(category.get_ancestors()), chain[:-1])
def test_category_list(self): def test_category_list(self):
"""Test the PartCategoryList API endpoint.""" """Test the PartCategoryList API endpoint."""
url = reverse('api-part-category-list') url = reverse('api-part-category-list')
@@ -1440,6 +1512,75 @@ class PartAPITest(PartAPITestBase):
for field in ['name', 'description', 'structural']: for field in ['name', 'description', 'structural']:
self.assertIn(field, category) self.assertIn(field, category)
@override_settings(
TESTING_TABLE_EVENTS=True,
PLUGIN_TESTING_EVENTS=True,
PLUGIN_TESTING_EVENTS_ASYNC=True,
)
def test_bulk_update(self):
"""Test that we can bulk-update a set of parts via the API.
Test that:
- All parts are updated correctly
- Instance saved events are offloaded to the background worker
"""
from django_q.models import OrmQ
self.assignRole('part.change')
set_global_setting('ENABLE_PLUGINS_EVENTS', True)
# Create a bunch of parts
parts = [
Part.objects.create(
name=f'Bulk part {i}',
description='A part for bulk update testing',
category=PartCategory.objects.first(),
active=True,
)
for i in range(10)
]
for part in parts:
self.assertTrue(part.active)
# Clear out event registry
OrmQ.objects.all().delete()
# Bulk update all parts to be inactive
response = self.patch(
reverse('api-part-list'),
{'active': False, 'items': [part.pk for part in parts]},
expected_code=200,
benchmark=True,
max_query_count=250,
max_query_time=2.0,
)
self.assertEqual(len(response.data['items']), len(parts))
# Check that events have been registered
self.assertGreaterEqual(OrmQ.objects.count(), 2 * len(parts))
# Check that 'active' parameter has been updated for all parts
for part in parts:
part.refresh_from_db()
self.assertFalse(part.active)
self.assertIsNotNone(
findOffloadedEvent('part_part.saved', matching_kwargs={'id': part.pk}),
f'part_part.saved event not found for part {part.pk} not found in offloaded events',
)
self.assertIsNotNone(
findOffloadedTask(
'part.tasks.rebuild_supplier_parts', matching_args=[part.pk]
),
f'rebuild_supplier_parts task not found for part {part.pk} not found in offloaded tasks',
)
set_global_setting('ENABLE_PLUGINS_EVENTS', False)
class PartCreationTests(PartAPITestBase): class PartCreationTests(PartAPITestBase):
"""Tests for creating new Part instances via the API.""" """Tests for creating new Part instances via the API."""
@@ -1746,6 +1887,51 @@ class PartCreationTests(PartAPITestBase):
prt = Part.objects.get(pk=data['pk']) prt = Part.objects.get(pk=data['pk'])
self.assertEqual(prt.parameters.count(), 3) 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): class PartDetailTests(PartImageTestMixin, PartAPITestBase):
"""Test that we can create / edit / delete Part objects via the API.""" """Test that we can create / edit / delete Part objects via the API."""
+1 -3
View File
@@ -37,9 +37,7 @@ class ReportSerializerBase(InvenTreeModelSerializer):
_('User must be authenticated to save report templates') _('User must be authenticated to save report templates')
) )
instance = super().save(**kwargs) instance = super().save(updated_by=user, **kwargs)
instance.updated_by = user
instance.save(increment_revision=False)
return instance return instance
+151 -9
View File
@@ -550,6 +550,9 @@ class StockItem(
def delete(self, ignore_serial_check: bool = False, **kwargs): def delete(self, ignore_serial_check: bool = False, **kwargs):
"""Custom delete method for StockItem model. """Custom delete method for StockItem model.
Any child items are re-linked to the parent of this item,
to preserve the stock item genealogy chain.
Arguments: Arguments:
ignore_serial_check: If True, allow deletion of serialized stock items regardless of global setting ignore_serial_check: If True, allow deletion of serialized stock items regardless of global setting
""" """
@@ -559,6 +562,13 @@ class StockItem(
if self.serialized: if self.serialized:
raise ValidationError(_('Serialized stock items cannot be deleted')) raise ValidationError(_('Serialized stock items cannot be deleted'))
with transaction.atomic():
# Re-link any child items to the parent of this item,
# so the genealogy chain survives deletion of an intermediate item
parent = StockItem.objects.filter(pk=self.parent_id).first()
StockItem.objects.filter(parent=self).update(parent=parent)
super().delete(**kwargs) super().delete(**kwargs)
@staticmethod @staticmethod
@@ -1036,7 +1046,7 @@ class StockItem(
"""Returns part name.""" """Returns part name."""
return self.part.full_name return self.part.full_name
# Note: When a StockItem is deleted, a pre_delete signal handles the parent/child relationship # Note: When a StockItem is deleted, child items are re-linked to its parent (see delete())
parent = models.ForeignKey( parent = models.ForeignKey(
'stock.StockItem', 'stock.StockItem',
verbose_name=_('Parent Stock Item'), verbose_name=_('Parent Stock Item'),
@@ -1360,6 +1370,7 @@ class StockItem(
# Delete outstanding BuildOrder allocations # Delete outstanding BuildOrder allocations
self.allocations.all().delete() self.allocations.all().delete()
@transaction.atomic
def allocateToCustomer( def allocateToCustomer(
self, customer, quantity=None, order=None, user=None, notes=None self, customer, quantity=None, order=None, user=None, notes=None
): ):
@@ -1376,9 +1387,20 @@ class StockItem(
user: User that performed the action user: User that performed the action
notes: Notes field notes: Notes field
""" """
if quantity is None: # Lock the database row, so concurrent adjustments are serialized
if not self.lock_quantity():
raise ValidationError(_('Stock item no longer exists'))
if quantity is None or self.serialized:
quantity = self.quantity quantity = self.quantity
# Cannot allocate more than the available quantity
# (also ensures the recorded history matches the actual allocation)
quantity = min(quantity, self.quantity)
if quantity <= 0:
raise ValidationError({'quantity': _('Quantity must be greater than zero')})
if quantity >= self.quantity: if quantity >= self.quantity:
item = self item = self
else: else:
@@ -1708,6 +1730,22 @@ class StockItem(
notes: Any notes associated with the operation notes: Any notes associated with the operation
build: The BuildOrder to associate with the operation (optional) build: The BuildOrder to associate with the operation (optional)
""" """
# Lock the database row, so concurrent adjustments are serialized
if not other_item.lock_quantity():
raise ValidationError(_('Stock item no longer exists'))
try:
quantity = Decimal(quantity)
except (InvalidOperation, TypeError):
raise ValidationError({'quantity': _('Invalid quantity value')})
if quantity <= 0:
raise ValidationError({'quantity': _('Quantity must be greater than zero')})
# Cannot install more than the available quantity
# (also ensures the recorded history matches the actual installation)
quantity = min(quantity, other_item.quantity)
# If the quantity is less than the stock item, split the stock! # If the quantity is less than the stock item, split the stock!
stock_item = other_item.splitStock(quantity, None, user) stock_item = other_item.splitStock(quantity, None, user)
@@ -1915,6 +1953,10 @@ class StockItem(
if quantity <= 0: if quantity <= 0:
raise ValidationError({'quantity': _('Quantity must be greater than zero')}) raise ValidationError({'quantity': _('Quantity must be greater than zero')})
# Lock the database row, so concurrent adjustments are serialized
if not self.lock_quantity():
raise ValidationError(_('Stock item no longer exists'))
if quantity > self.quantity: if quantity > self.quantity:
raise ValidationError({ raise ValidationError({
'quantity': _('Quantity must not exceed available stock quantity') 'quantity': _('Quantity must not exceed available stock quantity')
@@ -2234,6 +2276,10 @@ class StockItem(
if quantity <= 0: if quantity <= 0:
raise ValidationError({'quantity': _('Quantity must be greater than zero')}) raise ValidationError({'quantity': _('Quantity must be greater than zero')})
# Lock the database row, so concurrent adjustments are serialized
if not self.lock_quantity():
raise ValidationError(_('Stock item no longer exists'))
if quantity > self.quantity: if quantity > self.quantity:
raise ValidationError({ raise ValidationError({
'quantity': _( 'quantity': _(
@@ -2455,6 +2501,11 @@ class StockItem(
if location is None: if location is None:
return None return None
# This item must itself be in a state which allows merging
# (e.g. not serialized, in production, installed, or assigned to an order / customer)
if not self.can_merge():
return None
candidates = list( candidates = list(
StockItem.objects StockItem.objects
.filter(part=self.part, location=location) .filter(part=self.part, location=location)
@@ -2495,6 +2546,29 @@ class StockItem(
if len(other_items) == 0: if len(other_items) == 0:
return return
# Lock all database rows (in pk order, to avoid deadlocks between
# concurrent merges) and refresh the quantity of each item
items_to_merge = sorted([self, *other_items], key=lambda item: item.pk)
locked_quantities = dict(
StockItem.objects
.select_for_update()
.filter(pk__in=[item.pk for item in items_to_merge])
.values_list('pk', 'quantity')
)
for item in items_to_merge:
if item.pk not in locked_quantities:
if raise_error:
raise ValidationError(_('Stock item no longer exists'))
logger.warning(
'Stock item <%s> no longer exists - merge cancelled', item.pk
)
return
item.quantity = locked_quantities[item.pk]
user = kwargs.get('user') user = kwargs.get('user')
location = kwargs.get('location', self.location) location = kwargs.get('location', self.location)
notes = kwargs.get('notes') or '' notes = kwargs.get('notes') or ''
@@ -2508,10 +2582,14 @@ class StockItem(
pricing_data.append([self.purchase_price, self.quantity]) pricing_data.append([self.purchase_price, self.quantity])
for other in other_items: for other in other_items:
# If the stock item cannot be merged, return # Check the merge in both directions, so that the generic state checks
if not self.can_merge(other, raise_error=raise_error, **kwargs): # (serialized, in production, installed, assigned to an order / customer)
# are applied to the incoming items as well as this one
if not self.can_merge(
other, raise_error=raise_error, **kwargs
) or not other.can_merge(self, raise_error=raise_error, **kwargs):
logger.warning( logger.warning(
'Stock item <%s> could not be merge into <%s>', other.pk, self.pk 'Stock item <%s> could not be merged into <%s>', other.pk, self.pk
) )
return return
@@ -2648,6 +2726,10 @@ class StockItem(
if quantity <= 0: if quantity <= 0:
return None return None
# Lock the database row, so concurrent adjustments are serialized
if not self.lock_quantity():
return None
# Also doesn't make sense to split the full amount # Also doesn't make sense to split the full amount
if quantity >= self.quantity: if quantity >= self.quantity:
return None return None
@@ -2815,6 +2897,10 @@ class StockItem(
""" """
current_location = self.location current_location = self.location
# Lock the database row, so concurrent adjustments are serialized
if not self.lock_quantity():
return False
try: try:
quantity = Decimal(kwargs.pop('quantity', self.quantity)) quantity = Decimal(kwargs.pop('quantity', self.quantity))
except InvalidOperation: except InvalidOperation:
@@ -2884,6 +2970,37 @@ class StockItem(
return True return True
def lock_quantity(self) -> bool:
"""Acquire a database row-level lock on this StockItem.
Once the lock is held, the 'quantity' field is refreshed from the database,
so that read-modify-write adjustments operate on the current committed value
rather than a (potentially stale) in-memory copy.
Note: Must be called from within a database transaction,
and the lock is held until that transaction completes.
Returns:
False if this StockItem no longer exists in the database.
"""
if not self.pk:
return False
quantity = (
StockItem.objects
.select_for_update()
.filter(pk=self.pk)
.values_list('quantity', flat=True)
.first()
)
if quantity is None:
return False
self.quantity = quantity
return True
@transaction.atomic @transaction.atomic
def updateQuantity(self, quantity): def updateQuantity(self, quantity):
"""Update stock quantity for this item. """Update stock quantity for this item.
@@ -2946,6 +3063,10 @@ class StockItem(
if count < 0: if count < 0:
return False return False
# Lock the database row, so concurrent adjustments are serialized
if not self.lock_quantity():
return False
tracking_info = {} tracking_info = {}
location = kwargs.pop('location', None) location = kwargs.pop('location', None)
@@ -2958,6 +3079,15 @@ class StockItem(
status = self._resolve_status_kwarg(kwargs) status = self._resolve_status_kwarg(kwargs)
self._apply_status_change(status, tracking_info) self._apply_status_change(status, tracking_info)
# Optional fields which can be supplied in a 'stocktake' call
self._apply_optional_transfer_fields(kwargs, tracking_info)
# Have any fields (other than quantity) been updated?
# Must be determined *before* model reference deltas are extracted,
# as those only affect the tracking entry (not the model instance)
fields_updated = len(tracking_info) > 0
self._apply_model_reference_fields(kwargs, tracking_info) self._apply_model_reference_fields(kwargs, tracking_info)
quantity_updated = self.serialized or self.updateQuantity(count) quantity_updated = self.serialized or self.updateQuantity(count)
@@ -2966,13 +3096,13 @@ class StockItem(
# (self.quantity is updated by updateQuantity() even if the item was deleted) # (self.quantity is updated by updateQuantity() even if the item was deleted)
tracking_info['quantity'] = 1 if self.serialized else float(self.quantity) tracking_info['quantity'] = 1 if self.serialized else float(self.quantity)
if quantity_updated: # Save if the quantity or any other field was changed.
# Note that updateQuantity() may have *deleted* the item (depleted to zero),
# in which case there is nothing left to save.
if self.pk and (quantity_updated or fields_updated):
self.stocktake_date = InvenTree.helpers.current_date() self.stocktake_date = InvenTree.helpers.current_date()
self.stocktake_user = user self.stocktake_user = user
# Optional fields which can be supplied in a 'stocktake' call
self._apply_optional_transfer_fields(kwargs, tracking_info)
self.save(add_note=False) self.save(add_note=False)
trigger_event( trigger_event(
@@ -3016,6 +3146,10 @@ class StockItem(
if quantity <= 0: if quantity <= 0:
return False return False
# Lock the database row, so concurrent adjustments are serialized
if not self.lock_quantity():
return False
tracking_info = {} tracking_info = {}
status = self._resolve_status_kwarg(kwargs) status = self._resolve_status_kwarg(kwargs)
@@ -3065,9 +3199,17 @@ class StockItem(
except InvalidOperation: except InvalidOperation:
return False return False
# Cannot remove more than the available quantity
# (also ensures the recorded history matches the actual removal)
quantity = min(quantity, self.quantity)
if quantity <= 0: if quantity <= 0:
return False return False
# Lock the database row, so concurrent adjustments are serialized
if not self.lock_quantity():
return False
deltas = {} deltas = {}
status = self._resolve_status_kwarg(kwargs) status = self._resolve_status_kwarg(kwargs)
@@ -1993,6 +1993,10 @@ class StockAdjustmentSerializer(serializers.Serializer):
if len(items) == 0: if len(items) == 0:
raise ValidationError(_('A list of stock items must be provided')) raise ValidationError(_('A list of stock items must be provided'))
# Process items in stable (pk) order, so that concurrent multi-item
# requests acquire database row locks in the same order (deadlock avoidance)
data['items'] = sorted(items, key=lambda entry: entry['pk'].pk)
return data return data
+198 -3
View File
@@ -6,6 +6,7 @@ from datetime import datetime, timedelta
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.test.utils import override_settings
from django.urls import reverse from django.urls import reverse
import pytest import pytest
@@ -18,7 +19,11 @@ import order.models
import part.models import part.models
from common.models import InvenTreeCustomUserStateModel, InvenTreeSetting from common.models import InvenTreeCustomUserStateModel, InvenTreeSetting
from common.settings import set_global_setting from common.settings import set_global_setting
from InvenTree.unit_test import InvenTreeAPIPerformanceTestCase, InvenTreeAPITestCase from InvenTree.unit_test import (
InvenTreeAPIPerformanceTestCase,
InvenTreeAPITestCase,
findOffloadedEvent,
)
from part.models import Part, PartTestTemplate from part.models import Part, PartTestTemplate
from stock.models import ( from stock.models import (
StockItem, StockItem,
@@ -73,6 +78,71 @@ class StockLocationTest(StockAPITestCase):
for ordering in ['name', 'pathstring', 'level', 'tree_id']: for ordering in ['name', 'pathstring', 'level', 'tree_id']:
self.run_ordering_test(self.list_url, ordering) self.run_ordering_test(self.list_url, ordering)
def test_bulk_set_parent(self):
"""Test that bulk re-parenting of locations correctly rebuilds the tree.
Re-parenting multiple locations in a single 'bulk update' API call must
leave the tree structure and the 'pathstring' values fully consistent.
Ref: https://github.com/inventree/InvenTree/issues/12394
"""
parent_a = StockLocation.objects.create(name='Parent A')
parent_b = StockLocation.objects.create(name='Parent B')
# Create a number of sub-locations under 'Parent A',
# some of which have their own child locations
locations = []
for i in range(25):
location = StockLocation.objects.create(name=f'Sub{i:02d}', parent=parent_a)
locations.append(location)
if i % 5 == 0:
child = StockLocation.objects.create(
name=f'Sub{i:02d}-child', parent=location
)
StockLocation.objects.create(
name=f'Sub{i:02d}-grandchild', parent=child
)
# Move all sub-locations to 'Parent B' in a single bulk update.
# The query count must scale linearly with the number of items.
# Note: when the background worker is not running (e.g. in tests), each
# item save runs the (de-duplicated) tree rebuild task synchronously
self.patch(
self.list_url,
{'items': [location.pk for location in locations], 'parent': parent_b.pk},
expected_code=200,
max_query_count=60 * len(locations),
)
for location in locations:
location.refresh_from_db()
self.assertEqual(location.parent, parent_b)
parent_a.refresh_from_db()
parent_b.refresh_from_db()
# Check *all* locations in the affected trees
# (fixture data outside these trees does not have pathstring values set)
affected_trees = StockLocation.objects.filter(
tree_id__in=[parent_a.tree_id, parent_b.tree_id]
)
for location in affected_trees:
# The stored pathstring must match the 'parent' chain for the node
chain = []
node = location
while node is not None:
chain.insert(0, node)
node = node.parent
self.assertEqual(location.pathstring, '/'.join(node.name for node in chain))
# The MPTT tree data must match the 'parent' chain, too
self.assertEqual(list(location.get_ancestors()), chain[:-1])
def test_list(self): def test_list(self):
"""Test the StockLocationList API endpoint.""" """Test the StockLocationList API endpoint."""
test_cases = [ test_cases = [
@@ -2227,7 +2297,7 @@ class StockItemTest(StockAPITestCase):
data = response.data data = response.data
self.assertEqual(data['success'], 'Updated 10 items') self.assertEqual(data['success'], 'Updated multiple items')
self.assertEqual(len(data['items']), 10) self.assertEqual(len(data['items']), 10)
for item in data['items']: for item in data['items']:
@@ -2945,6 +3015,56 @@ class StocktakeTest(StockAPITestCase):
str(response.data['location']), str(response.data['location']),
) )
def test_count_unchanged_quantity_saves_field_changes(self):
"""Location / status changes are saved even if the counted quantity is unchanged.
Regression test: counting an item to its *existing* quantity used to skip
the model save entirely, losing any location / status change while still
recording the change in stock history.
"""
item = StockItem.objects.get(pk=1234)
quantity = item.quantity
self.assertEqual(item.location.pk, 5)
self.assertEqual(item.status, StockStatus.OK.value)
# Clear any existing stocktake date so we can verify it gets set
item.stocktake_date = None
item.save()
self.post(
reverse('api-stock-count'),
{
'items': [
{
'pk': item.pk,
'quantity': float(quantity),
'status': StockStatus.DAMAGED.value,
}
],
'location': 1,
},
expected_code=201,
)
item.refresh_from_db()
# Quantity is unchanged, but the location and status changes must be saved
self.assertEqual(item.quantity, quantity)
self.assertEqual(item.location.pk, 1)
self.assertEqual(item.status, StockStatus.DAMAGED.value)
self.assertIsNotNone(item.stocktake_date)
# The stock history entry must agree with the saved state
entry = StockItemTracking.objects.filter(
item=item, tracking_type=StockHistoryCode.STOCK_COUNT
).latest('date')
self.assertEqual(entry.deltas.get('quantity'), float(quantity))
self.assertEqual(entry.deltas.get('location'), 1)
self.assertEqual(entry.deltas.get('old_location'), 5)
self.assertEqual(entry.deltas.get('status'), StockStatus.DAMAGED.value)
self.assertEqual(entry.deltas.get('old_status'), StockStatus.OK.value)
def test_bulk_count_query_benchmark(self): def test_bulk_count_query_benchmark(self):
"""Benchmark: measure the number of DB queries required to count 100 stock items at once.""" """Benchmark: measure the number of DB queries required to count 100 stock items at once."""
InvenTreeSetting.set_setting('ENABLE_PLUGINS_EVENTS', True, change_user=None) InvenTreeSetting.set_setting('ENABLE_PLUGINS_EVENTS', True, change_user=None)
@@ -3271,6 +3391,51 @@ class StockTransferMergeTest(StockAPITestCase):
).exists() ).exists()
) )
def test_transfer_merge_skips_protected_source(self):
"""Merge-on-transfer must not absorb items in a protected state.
Regression test: only the *target* item used to be validated, so a
transfer with merge=True could absorb (and delete) an in-production
build output. Such items must be moved as a separate lot instead.
"""
existing = StockItem.objects.create(
part=self.part, location=self.dest, quantity=100
)
# An "in production" build output
self.part.assembly = True
self.part.save()
bo = build.models.Build.objects.create(
reference='BO-9999', part=self.part, title='Merge test build', quantity=50
)
building = StockItem.objects.create(
part=self.part,
location=self.source_loc,
quantity=50,
build=bo,
is_building=True,
)
self.post(
self.url,
{
'items': [{'pk': building.pk, 'quantity': 50, 'merge': True}],
'location': self.dest.pk,
},
expected_code=201,
)
# The build output must survive - transferred as a separate lot instead
building.refresh_from_db()
self.assertTrue(building.is_building)
self.assertEqual(building.location, self.dest)
self.assertEqual(building.quantity, 50)
existing.refresh_from_db()
self.assertEqual(existing.quantity, 100)
class StockItemDeletionTest(StockAPITestCase): class StockItemDeletionTest(StockAPITestCase):
"""Tests for stock item deletion via the API.""" """Tests for stock item deletion via the API."""
@@ -3535,12 +3700,21 @@ class StockTestResultTest(StockAPITestCase):
# Check that an attachment has been uploaded # Check that an attachment has been uploaded
self.assertIsNotNone(response.data['attachment']) self.assertIsNotNone(response.data['attachment'])
@override_settings(
TESTING_TABLE_EVENTS=True,
PLUGIN_TESTING_EVENTS=True,
PLUGIN_TESTING_EVENTS_ASYNC=True,
)
def test_bulk_delete(self): def test_bulk_delete(self):
"""Test that the BulkDelete endpoint works for this model.""" """Test that the BulkDelete endpoint works for this model."""
from django_q.models import OrmQ
n = StockItemTestResult.objects.count() n = StockItemTestResult.objects.count()
tests = [] tests = []
set_global_setting('ENABLE_PLUGINS_EVENTS', True)
url = reverse('api-stock-test-result-list') url = reverse('api-stock-test-result-list')
stock_item = StockItem.objects.get(pk=1) stock_item = StockItem.objects.get(pk=1)
@@ -3580,11 +3754,32 @@ class StockTestResultTest(StockAPITestCase):
# Attempt a delete without providing items # Attempt a delete without providing items
self.delete(url, {}, expected_code=400) self.delete(url, {}, expected_code=400)
OrmQ.objects.all().delete()
# Now, let's delete all the newly created items with a single API request # Now, let's delete all the newly created items with a single API request
response = self.delete(url, {'items': tests}, expected_code=200) response = self.delete(
url,
{'items': tests},
expected_code=200,
max_query_count=100,
benchmark=True,
max_query_time=0.5,
)
self.assertEqual(StockItemTestResult.objects.count(), n) self.assertEqual(StockItemTestResult.objects.count(), n)
self.assertGreaterEqual(OrmQ.objects.count(), len(tests))
# Ensure that an associated 'deleted' event has been offloaded
for test in tests:
self.assertIsNotNone(
findOffloadedEvent(
'stock_stockitemtestresult.deleted', matching_kwargs={'id': test}
)
)
set_global_setting('ENABLE_PLUGINS_EVENTS', False)
def test_value_choices(self): def test_value_choices(self):
"""Test that the 'value' field is correctly validated.""" """Test that the 'value' field is correctly validated."""
url = reverse('api-stock-test-result-list') url = reverse('api-stock-test-result-list')
+264
View File
@@ -351,6 +351,157 @@ class StockTest(StockTestBase):
stock.splitStock(stock.quantity, None, self.user) stock.splitStock(stock.quantity, None, self.user)
self.assertEqual(StockItem.objects.filter(part=3).count(), n + 1) self.assertEqual(StockItem.objects.filter(part=3).count(), n + 1)
def test_delete_reparents_children(self):
"""Test that deleting an intermediate item re-links children to the grandparent."""
grandparent = StockItem.objects.get(id=1234)
parent = grandparent.splitStock(200, None, self.user)
child_a = parent.splitStock(50, None, self.user)
child_b = parent.splitStock(50, None, self.user)
self.assertEqual(parent.parent, grandparent)
self.assertEqual(child_a.parent, parent)
self.assertEqual(child_b.parent, parent)
# Deleting the intermediate item grafts its children onto the grandparent
parent.delete()
child_a.refresh_from_db()
child_b.refresh_from_db()
self.assertEqual(child_a.parent, grandparent)
self.assertEqual(child_b.parent, grandparent)
# Deleting a top-level item leaves its children with no parent
grandparent.delete()
child_a.refresh_from_db()
child_b.refresh_from_db()
self.assertIsNone(child_a.parent)
self.assertIsNone(child_b.parent)
def test_implicit_delete_reparents_children(self):
"""Test child re-linking when items are deleted by depletion or merging."""
grandparent = StockItem.objects.get(id=1234)
# An item depleted to zero with delete_on_deplete set is deleted
parent = grandparent.splitStock(200, None, self.user)
child = parent.splitStock(50, None, self.user)
parent.delete_on_deplete = True
parent.save()
self.assertTrue(parent.take_stock(150, self.user, notes='Deplete'))
self.assertFalse(StockItem.objects.filter(pk=parent.pk).exists())
child.refresh_from_db()
self.assertEqual(child.parent, grandparent)
# An item absorbed by a merge is also deleted
source = grandparent.splitStock(100, None, self.user)
kid = source.splitStock(25, None, self.user)
target = StockItem.objects.create(
part=grandparent.part,
supplier_part=grandparent.supplier_part,
quantity=10,
location=grandparent.location,
)
target.merge_stock_items([source], raise_error=True, user=self.user)
self.assertFalse(StockItem.objects.filter(pk=source.pk).exists())
kid.refresh_from_db()
self.assertEqual(kid.parent, grandparent)
def test_over_adjustment_quantities(self):
"""Stock adjustments are clamped to the available stock quantity.
Regression test: take_stock / allocateToCustomer / installStockItem
previously recorded the *requested* quantity in the stock history,
even when less stock was actually removed / allocated / installed.
"""
part = Part.objects.create(
name='Clamp part',
description='A part for quantity clamping tests',
salable=True,
component=True,
)
# --- take_stock: remove more than available ---
item = StockItem.objects.create(part=part, quantity=10, delete_on_deplete=False)
self.assertTrue(item.take_stock(25, self.user, notes='over-remove'))
item.refresh_from_db()
self.assertEqual(item.quantity, 0)
# History records the quantity which was *actually* removed
entry = item.tracking_info.latest('pk')
self.assertEqual(entry.deltas['removed'], 10.0)
self.assertEqual(entry.deltas['quantity'], 0.0)
# Removing stock from an empty item fails, and adds no history
n = item.tracking_info.count()
self.assertFalse(item.take_stock(5, self.user))
self.assertEqual(item.tracking_info.count(), n)
# --- allocateToCustomer: allocate more than available ---
customer = Company.objects.create(name='Clamp customer', is_customer=True)
item = StockItem.objects.create(part=part, quantity=10)
allocated = item.allocateToCustomer(customer, quantity=25, user=self.user)
# The whole item is allocated (no split occurs)
self.assertEqual(allocated.pk, item.pk)
self.assertEqual(allocated.customer, customer)
# History records the quantity which was *actually* allocated
entry = allocated.tracking_info.latest('pk')
self.assertEqual(entry.deltas['quantity'], 10.0)
# A zero quantity is rejected outright
item = StockItem.objects.create(part=part, quantity=10)
with self.assertRaises(ValidationError):
item.allocateToCustomer(customer, quantity=0, user=self.user)
# --- installStockItem: install more than available ---
assembly = Part.objects.create(
name='Clamp assembly',
description='An assembly for quantity clamping tests',
assembly=True,
)
parent_item = StockItem.objects.create(part=assembly, quantity=1)
component = StockItem.objects.create(part=part, quantity=10)
parent_item.installStockItem(component, 25, self.user, 'over-install')
component.refresh_from_db()
self.assertEqual(component.belongs_to, parent_item)
self.assertEqual(component.quantity, 10)
# Both history entries record the quantity which was *actually* installed
entry = component.tracking_info.filter(
tracking_type=StockHistoryCode.INSTALLED_INTO_ASSEMBLY
).latest('pk')
self.assertEqual(entry.deltas['quantity'], 10.0)
entry = parent_item.tracking_info.filter(
tracking_type=StockHistoryCode.INSTALLED_CHILD_ITEM
).latest('pk')
self.assertEqual(entry.deltas['quantity'], 10.0)
# A zero quantity is rejected outright
other = StockItem.objects.create(part=part, quantity=5)
with self.assertRaises(ValidationError):
parent_item.installStockItem(other, 0, self.user, 'bad install')
def test_uninstall_into_structural_location(self): def test_uninstall_into_structural_location(self):
"""Test that an item cannot be uninstalled into a structural location.""" """Test that an item cannot be uninstalled into a structural location."""
parent = StockItem.objects.get(pk=1) parent = StockItem.objects.get(pk=1)
@@ -428,6 +579,71 @@ class StockTest(StockTestBase):
self.assertIn(grandchild, child_1.children.all()) self.assertIn(grandchild, child_1.children.all())
self.assertNotIn(grandchild, parent.children.all()) self.assertNotIn(grandchild, parent.children.all())
def test_adjustment_stale_quantity(self):
"""Stock adjustments operate on database quantities, not stale in-memory copies.
Simulates concurrent adjustment operations, where each worker holds
its own (stale) in-memory copy of the same StockItem.
"""
item = StockItem.objects.get(pk=1234)
self.assertEqual(item.quantity, 1234)
# Remove stock via two independent in-memory copies
item_a = StockItem.objects.get(pk=item.pk)
item_b = StockItem.objects.get(pk=item.pk)
self.assertTrue(item_a.take_stock(100, self.user))
self.assertTrue(item_b.take_stock(200, self.user))
item.refresh_from_db()
self.assertEqual(item.quantity, 934)
# Add stock via a stale copy
item_c = StockItem.objects.get(pk=item.pk)
item.take_stock(34, self.user)
self.assertTrue(item_c.add_stock(100, self.user))
item.refresh_from_db()
self.assertEqual(item.quantity, 1000)
# Split stock via a stale copy
item_d = StockItem.objects.get(pk=item.pk)
item.take_stock(500, self.user)
child = item_d.splitStock(300, None, self.user)
item.refresh_from_db()
self.assertEqual(item.quantity, 200)
self.assertEqual(child.quantity, 300)
# A full-quantity move via a stale copy must not resurrect removed stock
item_e = StockItem.objects.get(pk=item.pk)
item.take_stock(50, self.user)
self.assertTrue(item_e.move(self.diningroom, 'Move', self.user))
item.refresh_from_db()
self.assertEqual(item.quantity, 150)
self.assertEqual(item.location, self.diningroom)
def test_merge_stale_quantity(self):
"""Merging stock items uses database quantities, not stale in-memory values."""
part = Part.objects.get(pk=3)
target = StockItem.objects.create(part=part, quantity=100)
source = StockItem.objects.create(part=part, quantity=50)
# Hold a stale copy of the target, and adjust the real row underneath it
stale_target = StockItem.objects.get(pk=target.pk)
target.take_stock(60, self.user)
stale_target.merge_stock_items([source], user=self.user)
target.refresh_from_db()
self.assertEqual(target.quantity, 90)
self.assertFalse(StockItem.objects.filter(pk=source.pk).exists())
def test_stocktake(self): def test_stocktake(self):
"""Test stocktake function.""" """Test stocktake function."""
# Perform stocktake # Perform stocktake
@@ -923,6 +1139,54 @@ class StockTest(StockTestBase):
# Final purchase price should be the weighted average # Final purchase price should be the weighted average
self.assertAlmostEqual(s1.purchase_price.amount, 16.875, places=3) self.assertAlmostEqual(s1.purchase_price.amount, 16.875, places=3)
def test_merge_protected_items(self):
"""Stock items in a protected state cannot be absorbed by a merge.
Regression test: merge_stock_items() used to run the generic state checks
(in production, assigned to customer, etc.) against the *target* item only,
allowing e.g. a build output to be merged away and deleted.
"""
part = Part.objects.first()
part.stock_items.all().delete()
target = StockItem.objects.create(part=part, quantity=10)
# The incoming item is "in production" (a build output)
part.assembly = True
part.save()
bo = Build.objects.create(
reference='BO-9998', part=part, title='Merge test build', quantity=20
)
building = StockItem.objects.create(
part=part, quantity=20, build=bo, is_building=True
)
# Without raise_error, the merge is refused silently
target.merge_stock_items([building])
target.refresh_from_db()
building.refresh_from_db()
self.assertEqual(part.stock_items.count(), 2)
self.assertEqual(target.quantity, 10)
self.assertEqual(building.quantity, 20)
# With raise_error, the merge raises a ValidationError
with self.assertRaises(ValidationError):
target.merge_stock_items([building], raise_error=True)
# An item assigned to a customer is likewise protected
customer = Company.objects.create(name='MergeCust', is_customer=True)
assigned = StockItem.objects.create(part=part, quantity=5, customer=customer)
target.merge_stock_items([assigned])
target.refresh_from_db()
self.assertEqual(target.quantity, 10)
self.assertTrue(StockItem.objects.filter(pk=assigned.pk).exists())
def test_notify_low_stock(self): def test_notify_low_stock(self):
"""Test that the 'notify_low_stock' task is triggered correctly.""" """Test that the 'notify_low_stock' task is triggered correctly."""
FUNC_NAME = 'part.tasks.notify_low_stock_if_required' FUNC_NAME = 'part.tasks.notify_low_stock_if_required'
+19 -6
View File
@@ -1,13 +1,15 @@
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { Alert, Stack, Text } from '@mantine/core'; import { Alert, Stack, Text } from '@mantine/core';
import { ErrorBoundary, type FallbackRender } from '@sentry/react'; import { ErrorBoundary, type FallbackRender } from '@sentry/react';
import { IconExclamationCircle } from '@tabler/icons-react'; import { IconExclamationCircle, IconInfoCircle } from '@tabler/icons-react';
import { type ReactNode, useCallback } from 'react'; import { type ReactNode, useCallback, useState } from 'react';
export function DefaultFallback({ export function DefaultFallback({
title title,
}: Readonly<{ title: string }>): ReactNode { error
}: Readonly<{ title: string; error: string | null }>): ReactNode {
return ( return (
<>
<Alert <Alert
color='red' color='red'
icon={<IconExclamationCircle />} icon={<IconExclamationCircle />}
@@ -15,13 +17,19 @@ export function DefaultFallback({
> >
<Stack gap='xs'> <Stack gap='xs'>
<Text size='sm'> <Text size='sm'>
{t`An error occurred while rendering this component. Refer to the console for more information.`} {t`An error occurred while rendering this component.`}
</Text> </Text>
<Text size='sm'> <Text size='sm'>
{t`Try reloading the page, or contact your administrator if the problem persists.`} {t`Try reloading the page, or contact your administrator if the problem persists.`}
</Text> </Text>
</Stack> </Stack>
</Alert> </Alert>
{error && (
<Alert color='red' icon={<IconInfoCircle />} title={t`Error Details`}>
<Text size='sm'>{error}</Text>
</Alert>
)}
</>
); );
} }
@@ -34,17 +42,22 @@ export function Boundary({
label: string; label: string;
fallback?: React.ReactElement<any> | FallbackRender; fallback?: React.ReactElement<any> | FallbackRender;
}>): ReactNode { }>): ReactNode {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const onError = useCallback( const onError = useCallback(
(error: unknown, componentStack: string | undefined, eventId: string) => { (error: unknown, componentStack: string | undefined, eventId: string) => {
console.error(`ERR: Error rendering component: ${label}`); console.error(`ERR: Error rendering component: ${label}`);
console.error(error); console.error(error);
setErrorMessage(error instanceof Error ? error.message : String(error));
}, },
[] []
); );
return ( return (
<ErrorBoundary <ErrorBoundary
fallback={fallback ?? <DefaultFallback title={label} />} fallback={
fallback ?? <DefaultFallback title={label} error={errorMessage} />
}
onError={onError} onError={onError}
> >
{children} {children}
@@ -129,6 +129,18 @@ export function IPNColumn(props: TableColumnProps): TableColumn {
}; };
} }
export function RevisionColumn(props: TableColumnProps): TableColumn {
return {
accessor: 'part_detail.revision',
sortable: true,
switchable: true,
title: t`Revision`,
copyable: true,
defaultVisible: false,
...props
};
}
export type StockColumnProps = TableColumnProps & { export type StockColumnProps = TableColumnProps & {
nullMessage?: string | ReactNode; nullMessage?: string | ReactNode;
}; };
+2 -1
View File
@@ -111,7 +111,8 @@ export function useParameterTemplateFields(): ApiFormFieldSet {
active: true active: true
} }
}, },
enabled: {} enabled: {},
unique: {}
}; };
}, []); }, []);
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More