mirror of
https://github.com/inventree/InvenTree.git
synced 2026-07-18 12:36:11 +00:00
Merge branch 'master' into dependabot/github_actions/dependencies-4e575e2008
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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") }}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,40 +932,89 @@ 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.
|
||||||
try:
|
# Note that repeated calls are de-duplicated (per tree),
|
||||||
self.refresh_from_db()
|
# so a bulk operation results in a single rebuild per affected tree.
|
||||||
except TransactionManagementError:
|
ran_sync = self.__class__.offload_tree_rebuild(trees)
|
||||||
# If we are inside a transaction block, we cannot refresh from db
|
self._tree_rebuild_offloaded = True
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
# Any other error is unexpected
|
|
||||||
InvenTree.sentry.report_exception(e)
|
|
||||||
InvenTree.exceptions.log_error(f'{self.__class__.__name__}.save')
|
|
||||||
|
|
||||||
def partial_rebuild(self, tree_id: int) -> bool:
|
if ran_sync:
|
||||||
|
# The tree was rebuilt synchronously, so refresh the instance
|
||||||
|
try:
|
||||||
|
self.refresh_from_db()
|
||||||
|
except TransactionManagementError:
|
||||||
|
# If we are inside a transaction block, we cannot refresh from db
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
# Any other error is unexpected
|
||||||
|
InvenTree.sentry.report_exception(e)
|
||||||
|
InvenTree.exceptions.log_error(f'{self.__class__.__name__}.save')
|
||||||
|
|
||||||
|
@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.
|
||||||
|
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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': _(
|
||||||
|
|||||||
@@ -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
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +562,14 @@ class StockItem(
|
|||||||
if self.serialized:
|
if self.serialized:
|
||||||
raise ValidationError(_('Serialized stock items cannot be deleted'))
|
raise ValidationError(_('Serialized stock items cannot be deleted'))
|
||||||
|
|
||||||
super().delete(**kwargs)
|
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)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_api_url():
|
def get_api_url():
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -1,27 +1,35 @@
|
|||||||
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
|
<>
|
||||||
color='red'
|
<Alert
|
||||||
icon={<IconExclamationCircle />}
|
color='red'
|
||||||
title={`INVE-E17: ${t`Error rendering component`}: ${title}`}
|
icon={<IconExclamationCircle />}
|
||||||
>
|
title={`INVE-E17: ${t`Error rendering component`}: ${title}`}
|
||||||
<Stack gap='xs'>
|
>
|
||||||
<Text size='sm'>
|
<Stack gap='xs'>
|
||||||
{t`An error occurred while rendering this component. Refer to the console for more information.`}
|
<Text size='sm'>
|
||||||
</Text>
|
{t`An error occurred while rendering this component.`}
|
||||||
<Text size='sm'>
|
</Text>
|
||||||
{t`Try reloading the page, or contact your administrator if the problem persists.`}
|
<Text size='sm'>
|
||||||
</Text>
|
{t`Try reloading the page, or contact your administrator if the problem persists.`}
|
||||||
</Stack>
|
</Text>
|
||||||
</Alert>
|
</Stack>
|
||||||
|
</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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -111,7 +111,8 @@ export function useParameterTemplateFields(): ApiFormFieldSet {
|
|||||||
active: true
|
active: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled: {}
|
enabled: {},
|
||||||
|
unique: {}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
|
|||||||
+2061
-1965
File diff suppressed because it is too large
Load Diff
+1990
-1894
File diff suppressed because it is too large
Load Diff
+2086
-1990
File diff suppressed because it is too large
Load Diff
+2060
-1964
File diff suppressed because it is too large
Load Diff
+2057
-1961
File diff suppressed because it is too large
Load Diff
+2059
-1963
File diff suppressed because it is too large
Load Diff
+2060
-1964
File diff suppressed because it is too large
Load Diff
+2057
-1961
File diff suppressed because it is too large
Load Diff
+2047
-1951
File diff suppressed because it is too large
Load Diff
+2017
-1921
File diff suppressed because it is too large
Load Diff
+1990
-1894
File diff suppressed because it is too large
Load Diff
+1990
-1894
File diff suppressed because it is too large
Load Diff
+2060
-1964
File diff suppressed because it is too large
Load Diff
+1990
-1894
File diff suppressed because it is too large
Load Diff
+1990
-1894
File diff suppressed because it is too large
Load Diff
+2058
-1962
File diff suppressed because it is too large
Load Diff
+2002
-1906
File diff suppressed because it is too large
Load Diff
+2058
-1962
File diff suppressed because it is too large
Load Diff
+2061
-1965
File diff suppressed because it is too large
Load Diff
+2210
-2114
File diff suppressed because it is too large
Load Diff
+1991
-1895
File diff suppressed because it is too large
Load Diff
+1990
-1894
File diff suppressed because it is too large
Load Diff
+2058
-1962
File diff suppressed because it is too large
Load Diff
+2028
-1932
File diff suppressed because it is too large
Load Diff
+1992
-1896
File diff suppressed because it is too large
Load Diff
+2050
-1954
File diff suppressed because it is too large
Load Diff
+2063
-1967
File diff suppressed because it is too large
Load Diff
+1996
-1900
File diff suppressed because it is too large
Load Diff
+2228
-2132
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
Reference in New Issue
Block a user