Merge commit 'fcbe4302b9fda7682b1a85e7d315b2d1545a16bf' into line-discount

This commit is contained in:
Oliver Walters 2026-07-20 01:02:46 +00:00
commit 4a14e3ba85
108 changed files with 136552 additions and 127035 deletions

View File

@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- [#12393](https://github.com/inventree/InvenTree/pull/12393) adds "discount" attribute to order line items, allowing users to specify a discount for each line item on an order. The discount can be specified as either a percentage or a fixed amount, and is applied to the line item total when calculating the order total.
- [#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.
- [#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.

View File

@ -148,3 +148,4 @@ The following [global settings](../settings/global.md) are available for transfe
{{ globalsetting("TRANSFERORDER_ENABLED") }}
{{ globalsetting("TRANSFERORDER_REFERENCE_PATTERN") }}
{{ globalsetting("TRANSFERORDER_REQUIRE_RESPONSIBLE") }}
{{ globalsetting("TRANSFERORDER_EDIT_COMPLETED_ORDERS") }}

View File

@ -1,8 +1,8 @@
# This file was autogenerated by uv via the following command:
# uv pip compile docs/requirements.in -o docs/requirements.txt -c src/backend/requirements.txt
anyio==4.12.1 \
--hash=sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703 \
--hash=sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c
anyio==4.14.2 \
--hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \
--hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f
# via httpx
babel==2.18.0 \
--hash=sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d \
@ -11,22 +11,21 @@ babel==2.18.0 \
# -c src/backend/requirements.txt
# mkdocs-git-revision-date-localized-plugin
# mkdocs-material
backrefs==6.2 \
--hash=sha256:08aa7fae530c6b2361d7bdcbda1a7c454e330cc9dbcd03f5c23205e430e5c3be \
--hash=sha256:0fdc7b012420b6b144410342caeb8adc54c6866cf12064abc9bb211302e496f8 \
--hash=sha256:12df81596ab511f783b7d87c043ce26bc5b0288cf3bb03610fe76b8189282b2b \
--hash=sha256:664e33cd88c6840b7625b826ecf2555f32d491800900f5a541f772c485f7cda7 \
--hash=sha256:c3f4b9cb2af8cda0d87ab4f57800b57b95428488477be164dd2b47be54db0c90 \
--hash=sha256:e5f805ae09819caa1aa0623b4a83790e7028604aa2b8c73ba602c4454e665de7 \
--hash=sha256:f44ff4d48808b243b6c0cdc6231e22195c32f77046018141556c66f8bab72a49
backrefs==7.0 \
--hash=sha256:4989bb9e1e99eb23647c7160ed51fb21d0b41b5d200f2d3017da41e023097e82 \
--hash=sha256:a0fa7360c63509e9e077e174ef4e6d3c21c8db94189b9d957289ae6d794b9475 \
--hash=sha256:a6448b28180e3ca01134c9cf09dcebafad8531072e09903c5451748a05f24bc9 \
--hash=sha256:b57cd227ea556b0aed3dc9b8da4628db4eabc0402c6d7fcfc69283a93955f7e9 \
--hash=sha256:ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12 \
--hash=sha256:f2c52955d631b9e1ac4cd56209f0a3a946d592b98e7790e77699339ae01c102a
# via mkdocs-material
beautifulsoup4==4.14.3 \
--hash=sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb \
--hash=sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86
beautifulsoup4==4.15.0 \
--hash=sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7 \
--hash=sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9
# via mkdocs-mermaid2-plugin
bracex==2.6 \
--hash=sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952 \
--hash=sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7
bracex==3.0 \
--hash=sha256:3833e61c2f092d5aa0468fa2e6c6e990a306185abf763b6d122f0158e59c58a5 \
--hash=sha256:b73f718d6bd98d8419e45df02426c86e9967c179949f779340d6c3a8c83b9111
# via wcmatch
certifi==2026.6.17 \
--hash=sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432 \
@ -169,9 +168,9 @@ charset-normalizer==3.4.7 \
# via
# -c src/backend/requirements.txt
# requests
click==8.4.1 \
--hash=sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2 \
--hash=sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96
click==8.4.2 \
--hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \
--hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76
# via
# mkdocs
# neoteroi-mkdocs
@ -202,12 +201,13 @@ gitdb==4.0.12 \
--hash=sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571 \
--hash=sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf
# via gitpython
gitpython==3.1.50 \
--hash=sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc \
--hash=sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9
gitpython==3.1.52 \
--hash=sha256:79a36ee1f83523214a3f72d56cf1c4e490d577dc61af77e43dfe5862bd9da01a \
--hash=sha256:de0a8ad86274c6e75ae8b37dd055ba68f19818c813108642263227b20775b48e
# via mkdocs-git-revision-date-localized-plugin
griffelib==2.0.0 \
--hash=sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f
griffelib==2.1.0 \
--hash=sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813 \
--hash=sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00
# via mkdocstrings-python
h11==0.16.0 \
--hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
@ -246,9 +246,9 @@ jinja2==3.1.6 \
# mkdocstrings
# neoteroi-mkdocs
# properdocs
jsbeautifier==1.15.4 \
--hash=sha256:5bb18d9efb9331d825735fbc5360ee8f1aac5e52780042803943aa7f854f7592 \
--hash=sha256:72f65de312a3f10900d7685557f84cb61a9733c50dcc27271a39f5b0051bf528
jsbeautifier==2.0.3 \
--hash=sha256:9579d4e9dbaa00383f3efdff4c98c8140bb85ba319398e8b97cdaba27abd6ba3 \
--hash=sha256:f0190e279a2cdb827556ada63f41c9c63c11f8116ee06e264b24aa50311cbee3
# via mkdocs-mermaid2-plugin
markdown==3.10.2 \
--hash=sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950 \
@ -436,9 +436,9 @@ mkdocstrings[python]==1.0.4 \
# via
# -r docs/requirements.in
# mkdocstrings-python
mkdocstrings-python==2.0.3 \
--hash=sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12 \
--hash=sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8
mkdocstrings-python==2.0.5 \
--hash=sha256:30c837bbff016549f659fcba6539ac351303f0fd7e713c89a040611072236e9d \
--hash=sha256:3a4d92556ad39637e88af94a5374213af9a8e3040c3824ceaed04b486c017594
# via mkdocstrings
neoteroi-mkdocs==1.2.0 \
--hash=sha256:58e25cb1b9db093ffa8d12bdb33264bf567cac30fb964b56e0a493efa749ad6e \
@ -456,9 +456,9 @@ paginate==0.5.7 \
--hash=sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945 \
--hash=sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591
# via mkdocs-material
pathspec==1.0.4 \
--hash=sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645 \
--hash=sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723
pathspec==1.1.1 \
--hash=sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a \
--hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189
# via
# mkdocs
# mkdocs-macros-plugin
@ -480,9 +480,9 @@ pygments==2.20.0 \
# via
# mkdocs-material
# rich
pymdown-extensions==10.21.3 \
--hash=sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354 \
--hash=sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6
pymdown-extensions==11.0.1 \
--hash=sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2 \
--hash=sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0
# via
# mkdocs-material
# mkdocs-mermaid2-plugin
@ -606,15 +606,14 @@ six==1.17.0 \
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
# via
# -c src/backend/requirements.txt
# jsbeautifier
# python-dateutil
smmap==5.0.3 \
--hash=sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c \
--hash=sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f
# via gitdb
soupsieve==2.8.3 \
--hash=sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349 \
--hash=sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95
soupsieve==2.8.4 \
--hash=sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e \
--hash=sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65
# via beautifulsoup4
super-collections==0.6.2 \
--hash=sha256:0c8d8abacd9fad2c7c1c715f036c29f5db213f8cac65f24d45ecba12b4da187a \
@ -671,7 +670,7 @@ watchdog==6.0.0 \
# via
# mkdocs
# properdocs
wcmatch==10.1 \
--hash=sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a \
--hash=sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af
wcmatch==11.0 \
--hash=sha256:3a5977ace27e075eef67eb03d539563f1a19018b62881949a42932cf66926934 \
--hash=sha256:55d95c2447789712774b198ceec72939e88b5618f1f8f0a9b605bf7740b63b96
# via mkdocs-include-markdown-plugin

View File

@ -31,6 +31,8 @@ from common.settings import get_global_setting
from InvenTree import helpers, ready
from InvenTree.auth_overrides import registration_enabled
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 users.models import ApiToken
from users.permissions import check_user_permission, prefetch_rule_sets
@ -511,7 +513,7 @@ class BulkCreateMixin:
if has_unique_errors:
raise ValidationError(unique_errors)
with transaction.atomic():
with transaction.atomic(), batch_events(), batch_offload_tasks():
for item in data:
serializer = self.get_serializer(data=item)
if serializer.is_valid():
@ -595,7 +597,7 @@ class BulkUpdateMixin(BulkOperationMixin):
instance_data = []
with transaction.atomic():
with transaction.atomic(), batch_events(), batch_offload_tasks():
# Perform object update
# Note that we do not perform a bulk-update operation here,
# as we want to trigger any custom post_save methods on the model
@ -692,7 +694,7 @@ class CommonBulkDeleteMixin(BulkOperationMixin):
# Keep track of how many items we deleted
n_deleted = queryset.count()
with transaction.atomic():
with transaction.atomic(), batch_events(), batch_offload_tasks():
# Perform object deletion
# Note that we do not perform a bulk-delete operation here,
# as we want to trigger any custom post_delete methods on the model

View File

@ -1,14 +1,19 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 523
INVENTREE_API_VERSION = 524
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v523 -> 2026-07-14 : https://github.com/inventree/InvenTree/pull/12393
v524 -> 2026-07-20 : https://github.com/inventree/InvenTree/pull/12393
- Adds "discount" field to order line items (and extra line items)
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

View File

@ -947,9 +947,7 @@ class ParameterSerializer(
if not target_model_class.check_related_permission('change', user):
raise PermissionDenied(permission_error_msg)
instance = super().save(**kwargs)
instance.updated_by = user
instance.save()
instance = super().save(updated_by=user, **kwargs)
return instance

View File

@ -951,6 +951,14 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False,
'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': {
'name': _('Block Incomplete Item Tests'),
'description': _(

View File

@ -6,6 +6,7 @@ from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test.utils import override_settings
from django.urls import reverse
from PIL import Image
@ -13,7 +14,8 @@ from taggit.models import Tag
import common.models
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):
@ -569,6 +571,96 @@ class ParameterAPITests(InvenTreeAPITestCase):
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

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

View File

@ -781,7 +781,7 @@ class PurchaseOrderLineItemDetail(
class PurchaseOrderExtraLineList(
GeneralExtraLineList, OutputOptionsMixin, ListCreateAPI
GeneralExtraLineList, OutputOptionsMixin, ListCreateDestroyAPIView
):
"""API endpoint for accessing a list of PurchaseOrderExtraLine objects."""
@ -1073,7 +1073,10 @@ class SalesOrderLineItemOutputOptions(OutputConfiguration):
class SalesOrderLineItemList(
SalesOrderLineItemMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI
SalesOrderLineItemMixin,
DataExportViewMixin,
OutputOptionsMixin,
ListCreateDestroyAPIView,
):
"""API endpoint for accessing a list of SalesOrderLineItem objects."""
@ -1117,7 +1120,9 @@ class SalesOrderLineItemDetail(
output_options = SalesOrderLineItemOutputOptions
class SalesOrderExtraLineList(GeneralExtraLineList, OutputOptionsMixin, ListCreateAPI):
class SalesOrderExtraLineList(
GeneralExtraLineList, OutputOptionsMixin, ListCreateDestroyAPIView
):
"""API endpoint for accessing a list of SalesOrderExtraLine objects."""
queryset = models.SalesOrderExtraLine.objects.all()
@ -1787,7 +1792,10 @@ class ReturnOrderLineItemOutputOptions(OutputConfiguration):
class ReturnOrderLineItemList(
ReturnOrderLineItemMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI
ReturnOrderLineItemMixin,
DataExportViewMixin,
OutputOptionsMixin,
ListCreateDestroyAPIView,
):
"""API endpoint for accessing a list of ReturnOrderLineItemList objects."""
@ -1830,7 +1838,9 @@ class ReturnOrderLineItemDetail(
output_options = ReturnOrderLineItemOutputOptions
class ReturnOrderExtraLineList(GeneralExtraLineList, OutputOptionsMixin, ListCreateAPI):
class ReturnOrderExtraLineList(
GeneralExtraLineList, OutputOptionsMixin, ListCreateDestroyAPIView
):
"""API endpoint for accessing a list of ReturnOrderExtraLine objects."""
queryset = models.ReturnOrderExtraLine.objects.all()
@ -2325,7 +2335,10 @@ class TransferOrderLineItemOutputOptions(OutputConfiguration):
class TransferOrderLineItemList(
TransferOrderLineItemMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI
TransferOrderLineItemMixin,
DataExportViewMixin,
OutputOptionsMixin,
ListCreateDestroyAPIView,
):
"""API endpoint for accessing a list of TransferOrderLineItem objects."""

View File

@ -3495,7 +3495,7 @@ class TransferOrder(Order):
REFERENCE_PATTERN_SETTING = 'TRANSFERORDER_REFERENCE_PATTERN'
REQUIRE_RESPONSIBLE_SETTING = 'TRANSFERORDER_REQUIRE_RESPONSIBLE'
STATUS_CLASS = TransferOrderStatus
# UNLOCK_SETTING = 'TRANSFERORDER_EDIT_COMPLETED_ORDERS'
UNLOCK_SETTING = 'TRANSFERORDER_EDIT_COMPLETED_ORDERS'
class Meta:
"""Model meta options."""

View File

@ -900,16 +900,46 @@ class PurchaseOrderLineItemTest(OrderTest):
"""Test that we can bulk delete multiple PurchaseOrderLineItems via the API."""
n = models.PurchaseOrderLineItem.objects.count()
self.assignRole('purchase_order.delete')
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
self.delete(url, {'items': [1, 2]}, expected_code=200)
# We should have 2 less PurchaseOrderLineItems after deleting them
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):
"""Test that we can create a new PurchaseOrderLineItem via the API."""
self.assignRole('purchase_order.add')
@ -2245,6 +2275,49 @@ class SalesOrderLineItemTest(OrderTest):
self.filter({'allocated': 'true'}, 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):
"""Test filtering by allocation status for a SalesOrderLineItem."""
self.assignRole('sales_order.add')
@ -3458,6 +3531,53 @@ class ReturnOrderLineItemTests(InvenTreeAPITestCase):
line = models.ReturnOrderLineItem.objects.get(pk=1)
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 ExtraLineTotalPriceTest(InvenTreeAPITestCase):
"""Unit tests for the 'total_price' field on ExtraLine API endpoints.
@ -4171,6 +4291,63 @@ class TransferOrderLineItemTest(OrderTest):
self.filter({'allocated': 'true'}, 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):
"""Test filtering by allocation status for a TransferOrderLineItem."""
self.assignRole('transfer_order.add')

View File

@ -1038,12 +1038,13 @@ class PartSerializer(
initial_supplier = validated_data.pop('initial_supplier', None)
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'):
instance.creation_user = request.user
instance.save()
extra_data['creation_user'] = request.user
instance = super().create({**validated_data, **extra_data})
# Copy data from original Part
if duplicate:

View File

@ -7,7 +7,7 @@ from random import randint
from django.core.exceptions import ValidationError
from django.db import connection
from django.test.utils import CaptureQueriesContext
from django.test.utils import CaptureQueriesContext, override_settings
from django.urls import reverse
import pytest
@ -19,9 +19,15 @@ import company.models
import order.models
from build.status_codes import BuildStatus
from common.models import InvenTreeSetting, ParameterTemplate
from common.settings import set_global_setting
from company.models import Company, SupplierPart
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 part.models import (
BomItem,
@ -1506,6 +1512,75 @@ class PartAPITest(PartAPITestBase):
for field in ['name', 'description', 'structural']:
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):
"""Tests for creating new Part instances via the API."""

View File

@ -37,9 +37,7 @@ class ReportSerializerBase(InvenTreeModelSerializer):
_('User must be authenticated to save report templates')
)
instance = super().save(**kwargs)
instance.updated_by = user
instance.save(increment_revision=False)
instance = super().save(updated_by=user, **kwargs)
return instance

View File

@ -6,6 +6,7 @@ from datetime import datetime, timedelta
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.test.utils import override_settings
from django.urls import reverse
import pytest
@ -18,7 +19,11 @@ import order.models
import part.models
from common.models import InvenTreeCustomUserStateModel, InvenTreeSetting
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 stock.models import (
StockItem,
@ -3695,12 +3700,21 @@ class StockTestResultTest(StockAPITestCase):
# Check that an attachment has been uploaded
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):
"""Test that the BulkDelete endpoint works for this model."""
from django_q.models import OrmQ
n = StockItemTestResult.objects.count()
tests = []
set_global_setting('ENABLE_PLUGINS_EVENTS', True)
url = reverse('api-stock-test-result-list')
stock_item = StockItem.objects.get(pk=1)
@ -3740,11 +3754,32 @@ class StockTestResultTest(StockAPITestCase):
# Attempt a delete without providing items
self.delete(url, {}, expected_code=400)
OrmQ.objects.all().delete()
# 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.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):
"""Test that the 'value' field is correctly validated."""
url = reverse('api-stock-test-result-list')

View File

@ -1,27 +1,35 @@
import { t } from '@lingui/core/macro';
import { Alert, Stack, Text } from '@mantine/core';
import { ErrorBoundary, type FallbackRender } from '@sentry/react';
import { IconExclamationCircle } from '@tabler/icons-react';
import { type ReactNode, useCallback } from 'react';
import { IconExclamationCircle, IconInfoCircle } from '@tabler/icons-react';
import { type ReactNode, useCallback, useState } from 'react';
export function DefaultFallback({
title
}: Readonly<{ title: string }>): ReactNode {
title,
error
}: Readonly<{ title: string; error: string | null }>): ReactNode {
return (
<Alert
color='red'
icon={<IconExclamationCircle />}
title={`INVE-E17: ${t`Error rendering component`}: ${title}`}
>
<Stack gap='xs'>
<Text size='sm'>
{t`An error occurred while rendering this component. Refer to the console for more information.`}
</Text>
<Text size='sm'>
{t`Try reloading the page, or contact your administrator if the problem persists.`}
</Text>
</Stack>
</Alert>
<>
<Alert
color='red'
icon={<IconExclamationCircle />}
title={`INVE-E17: ${t`Error rendering component`}: ${title}`}
>
<Stack gap='xs'>
<Text size='sm'>
{t`An error occurred while rendering this component.`}
</Text>
<Text size='sm'>
{t`Try reloading the page, or contact your administrator if the problem persists.`}
</Text>
</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;
fallback?: React.ReactElement<any> | FallbackRender;
}>): ReactNode {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const onError = useCallback(
(error: unknown, componentStack: string | undefined, eventId: string) => {
console.error(`ERR: Error rendering component: ${label}`);
console.error(error);
setErrorMessage(error instanceof Error ? error.message : String(error));
},
[]
);
return (
<ErrorBoundary
fallback={fallback ?? <DefaultFallback title={label} />}
fallback={
fallback ?? <DefaultFallback title={label} error={errorMessage} />
}
onError={onError}
>
{children}

View File

@ -0,0 +1,132 @@
import { t } from '@lingui/core/macro';
import { Group, Text } from '@mantine/core';
import type { ReactNode } from 'react';
import { formatDecimal } from '../../defaults/formatters';
import { TableHoverCard } from './TableHoverCard';
export function renderPartStockCell(record: any): ReactNode {
if (record.virtual) {
return (
<Text size='sm' c='dimmed' fs='italic'>
{t`Virtual part`}
</Text>
);
}
const extra: ReactNode[] = [];
const stock = record?.total_in_stock ?? 0;
const allocated =
(record?.allocated_to_build_orders ?? 0) +
(record?.allocated_to_sales_orders ?? 0);
const available = Math.max(0, stock - allocated);
const min_stock = record?.minimum_stock ?? 0;
const max_stock = record?.maximum_stock ?? 0;
let text = String(formatDecimal(stock));
let color: string | undefined = undefined;
if (min_stock > stock) {
extra.push(
<Text key='min-stock' c='orange'>
{`${t`Minimum stock`}: ${formatDecimal(min_stock)}`}
</Text>
);
color = 'orange';
}
if (max_stock > 0 && stock > max_stock) {
extra.push(
<Text key='max-stock' c='teal'>
{`${t`Maximum stock`}: ${formatDecimal(max_stock)}`}
</Text>
);
}
if (record.ordering > 0) {
extra.push(
<Text key='on-order'>{`${t`On Order`}: ${formatDecimal(record.ordering)}`}</Text>
);
}
if (record.building) {
extra.push(
<Text key='building'>{`${t`Building`}: ${formatDecimal(record.building)}`}</Text>
);
}
if (record.allocated_to_build_orders > 0) {
extra.push(
<Text key='bo-allocations'>
{`${t`Build Order Allocations`}: ${formatDecimal(record.allocated_to_build_orders)}`}
</Text>
);
}
if (record.allocated_to_sales_orders > 0) {
extra.push(
<Text key='so-allocations'>
{`${t`Sales Order Allocations`}: ${formatDecimal(record.allocated_to_sales_orders)}`}
</Text>
);
}
if (available != stock) {
extra.push(
<Text key='available'>
{t`Available`}: {formatDecimal(available)}
</Text>
);
}
if (record.external_stock > 0) {
extra.push(
<Text key='external'>
{t`External stock`}: {formatDecimal(record.external_stock)}
</Text>
);
}
if ((record.variant_stock ?? 0) > 0) {
extra.push(
<Text key='variant-stock' size='sm'>
{t`Includes variant stock`}: {formatDecimal(record.variant_stock)}
</Text>
);
extra.push(
<Text key='direct-stock' size='sm'>
{t`Direct stock`}: {formatDecimal(record.in_stock ?? 0)}
</Text>
);
}
if (stock <= 0) {
color = 'red';
text = t`No stock`;
} else if (available <= 0) {
color = 'orange';
} else if (available < min_stock) {
color = 'yellow';
}
return (
<TableHoverCard
value={
<Group gap='xs' justify='left' wrap='nowrap'>
<Text c={color} size='sm'>
{text}
</Text>
{record.units && (
<Text size='xs' c={color}>
[{record.units}]
</Text>
)}
</Group>
}
title={t`Stock Information`}
extra={extra}
/>
);
}

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

View File

@ -45,9 +45,12 @@ export function SecurityContent() {
const user = useUserState();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const onError = useCallback(
(error: unknown, componentStack: string | undefined, eventId: string) => {
console.error(`ERR: Error rendering component: ${error}`);
setErrorMessage(error instanceof Error ? error.message : String(error));
},
[]
);
@ -95,7 +98,9 @@ export function SecurityContent() {
</Accordion.Control>
<Accordion.Panel>
<ErrorBoundary
fallback={<DefaultFallback title={'API Table'} />}
fallback={
<DefaultFallback title={'API Table'} error={errorMessage} />
}
onError={onError}
>
<ApiTokenTable only_myself />

View File

@ -390,7 +390,8 @@ export default function SystemSettings() {
keys={[
'TRANSFERORDER_ENABLED',
'TRANSFERORDER_REFERENCE_PATTERN',
'TRANSFERORDER_REQUIRE_RESPONSIBLE'
'TRANSFERORDER_REQUIRE_RESPONSIBLE',
'TRANSFERORDER_EDIT_COMPLETED_ORDERS'
]}
/>
)

View File

@ -69,13 +69,11 @@ export default function TransferOrderDetail() {
const orderOpen: boolean =
order.status != toStatus.COMPLETE && order.status != toStatus.CANCELLED;
return orderOpen;
// TODO: does this setting make any sense for Transfer Orders???
// if (orderOpen) {
// return true;
// } else {
// return globalSettings.isSet('TRANSFERORDER_EDIT_COMPLETED_ORDERS');
// }
if (orderOpen) {
return true;
} else {
return globalSettings.isSet('TRANSFERORDER_EDIT_COMPLETED_ORDERS');
}
}, [globalSettings, order.status, toStatus]);
// for now, only permit editing allocations when line items can be edited

View File

@ -184,6 +184,9 @@ export default function ExtraLineItemTable({
params: {
order: orderId
},
enableSelection: true,
enableBulkDelete: editable && user.hasDeleteRole(role),
afterBulkDelete: orderDetailRefresh,
defaultSortColumn: 'line',
rowActions: rowActions,
tableActions: tableActions

View File

@ -7,6 +7,7 @@ import {
DescriptionColumn,
PartColumn
} from '../../components/tables/ColumnRenderers';
import { renderPartStockCell } from '../../components/tables/PartStockCell';
import ParametricDataTable from '../general/ParametricDataTable';
import { PartTableFilters } from './PartTableFilters';
@ -33,7 +34,8 @@ export default function ParametricPartTable({
},
{
accessor: 'total_in_stock',
sortable: true
sortable: true,
render: renderPartStockCell
}
];
}, []);

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