Merge commit 'fcbe4302b9fda7682b1a85e7d315b2d1545a16bf' into line-discount

This commit is contained in:
Oliver Walters
2026-07-20 01:02:46 +00:00
108 changed files with 136552 additions and 127035 deletions
+1
View File
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### 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. - [#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. - [#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.
+1
View File
@@ -148,3 +148,4 @@ The following [global settings](../settings/global.md) are available for transfe
{{ globalsetting("TRANSFERORDER_ENABLED") }} {{ globalsetting("TRANSFERORDER_ENABLED") }}
{{ globalsetting("TRANSFERORDER_REFERENCE_PATTERN") }} {{ globalsetting("TRANSFERORDER_REFERENCE_PATTERN") }}
{{ globalsetting("TRANSFERORDER_REQUIRE_RESPONSIBLE") }} {{ globalsetting("TRANSFERORDER_REQUIRE_RESPONSIBLE") }}
{{ globalsetting("TRANSFERORDER_EDIT_COMPLETED_ORDERS") }}
+43 -44
View File
@@ -1,8 +1,8 @@
# This file was autogenerated by uv via the following command: # 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 # uv pip compile docs/requirements.in -o docs/requirements.txt -c src/backend/requirements.txt
anyio==4.12.1 \ anyio==4.14.2 \
--hash=sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703 \ --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \
--hash=sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f
# via httpx # via httpx
babel==2.18.0 \ babel==2.18.0 \
--hash=sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d \ --hash=sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d \
@@ -11,22 +11,21 @@ babel==2.18.0 \
# -c src/backend/requirements.txt # -c src/backend/requirements.txt
# mkdocs-git-revision-date-localized-plugin # mkdocs-git-revision-date-localized-plugin
# mkdocs-material # mkdocs-material
backrefs==6.2 \ backrefs==7.0 \
--hash=sha256:08aa7fae530c6b2361d7bdcbda1a7c454e330cc9dbcd03f5c23205e430e5c3be \ --hash=sha256:4989bb9e1e99eb23647c7160ed51fb21d0b41b5d200f2d3017da41e023097e82 \
--hash=sha256:0fdc7b012420b6b144410342caeb8adc54c6866cf12064abc9bb211302e496f8 \ --hash=sha256:a0fa7360c63509e9e077e174ef4e6d3c21c8db94189b9d957289ae6d794b9475 \
--hash=sha256:12df81596ab511f783b7d87c043ce26bc5b0288cf3bb03610fe76b8189282b2b \ --hash=sha256:a6448b28180e3ca01134c9cf09dcebafad8531072e09903c5451748a05f24bc9 \
--hash=sha256:664e33cd88c6840b7625b826ecf2555f32d491800900f5a541f772c485f7cda7 \ --hash=sha256:b57cd227ea556b0aed3dc9b8da4628db4eabc0402c6d7fcfc69283a93955f7e9 \
--hash=sha256:c3f4b9cb2af8cda0d87ab4f57800b57b95428488477be164dd2b47be54db0c90 \ --hash=sha256:ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12 \
--hash=sha256:e5f805ae09819caa1aa0623b4a83790e7028604aa2b8c73ba602c4454e665de7 \ --hash=sha256:f2c52955d631b9e1ac4cd56209f0a3a946d592b98e7790e77699339ae01c102a
--hash=sha256:f44ff4d48808b243b6c0cdc6231e22195c32f77046018141556c66f8bab72a49
# via mkdocs-material # via mkdocs-material
beautifulsoup4==4.14.3 \ beautifulsoup4==4.15.0 \
--hash=sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb \ --hash=sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7 \
--hash=sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86 --hash=sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9
# via mkdocs-mermaid2-plugin # via mkdocs-mermaid2-plugin
bracex==2.6 \ bracex==3.0 \
--hash=sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952 \ --hash=sha256:3833e61c2f092d5aa0468fa2e6c6e990a306185abf763b6d122f0158e59c58a5 \
--hash=sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7 --hash=sha256:b73f718d6bd98d8419e45df02426c86e9967c179949f779340d6c3a8c83b9111
# via wcmatch # via wcmatch
certifi==2026.6.17 \ certifi==2026.6.17 \
--hash=sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432 \ --hash=sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432 \
@@ -169,9 +168,9 @@ charset-normalizer==3.4.7 \
# via # via
# -c src/backend/requirements.txt # -c src/backend/requirements.txt
# requests # requests
click==8.4.1 \ click==8.4.2 \
--hash=sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2 \ --hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \
--hash=sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96 --hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76
# via # via
# mkdocs # mkdocs
# neoteroi-mkdocs # neoteroi-mkdocs
@@ -202,12 +201,13 @@ gitdb==4.0.12 \
--hash=sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571 \ --hash=sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571 \
--hash=sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf --hash=sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf
# via gitpython # via gitpython
gitpython==3.1.50 \ gitpython==3.1.52 \
--hash=sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc \ --hash=sha256:79a36ee1f83523214a3f72d56cf1c4e490d577dc61af77e43dfe5862bd9da01a \
--hash=sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9 --hash=sha256:de0a8ad86274c6e75ae8b37dd055ba68f19818c813108642263227b20775b48e
# via mkdocs-git-revision-date-localized-plugin # via mkdocs-git-revision-date-localized-plugin
griffelib==2.0.0 \ griffelib==2.1.0 \
--hash=sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f --hash=sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813 \
--hash=sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00
# via mkdocstrings-python # via mkdocstrings-python
h11==0.16.0 \ h11==0.16.0 \
--hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
@@ -246,9 +246,9 @@ jinja2==3.1.6 \
# mkdocstrings # mkdocstrings
# neoteroi-mkdocs # neoteroi-mkdocs
# properdocs # properdocs
jsbeautifier==1.15.4 \ jsbeautifier==2.0.3 \
--hash=sha256:5bb18d9efb9331d825735fbc5360ee8f1aac5e52780042803943aa7f854f7592 \ --hash=sha256:9579d4e9dbaa00383f3efdff4c98c8140bb85ba319398e8b97cdaba27abd6ba3 \
--hash=sha256:72f65de312a3f10900d7685557f84cb61a9733c50dcc27271a39f5b0051bf528 --hash=sha256:f0190e279a2cdb827556ada63f41c9c63c11f8116ee06e264b24aa50311cbee3
# via mkdocs-mermaid2-plugin # via mkdocs-mermaid2-plugin
markdown==3.10.2 \ markdown==3.10.2 \
--hash=sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950 \ --hash=sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950 \
@@ -436,9 +436,9 @@ mkdocstrings[python]==1.0.4 \
# via # via
# -r docs/requirements.in # -r docs/requirements.in
# mkdocstrings-python # mkdocstrings-python
mkdocstrings-python==2.0.3 \ mkdocstrings-python==2.0.5 \
--hash=sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12 \ --hash=sha256:30c837bbff016549f659fcba6539ac351303f0fd7e713c89a040611072236e9d \
--hash=sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8 --hash=sha256:3a4d92556ad39637e88af94a5374213af9a8e3040c3824ceaed04b486c017594
# via mkdocstrings # via mkdocstrings
neoteroi-mkdocs==1.2.0 \ neoteroi-mkdocs==1.2.0 \
--hash=sha256:58e25cb1b9db093ffa8d12bdb33264bf567cac30fb964b56e0a493efa749ad6e \ --hash=sha256:58e25cb1b9db093ffa8d12bdb33264bf567cac30fb964b56e0a493efa749ad6e \
@@ -456,9 +456,9 @@ paginate==0.5.7 \
--hash=sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945 \ --hash=sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945 \
--hash=sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591 --hash=sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591
# via mkdocs-material # via mkdocs-material
pathspec==1.0.4 \ pathspec==1.1.1 \
--hash=sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645 \ --hash=sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a \
--hash=sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723 --hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189
# via # via
# mkdocs # mkdocs
# mkdocs-macros-plugin # mkdocs-macros-plugin
@@ -480,9 +480,9 @@ pygments==2.20.0 \
# via # via
# mkdocs-material # mkdocs-material
# rich # rich
pymdown-extensions==10.21.3 \ pymdown-extensions==11.0.1 \
--hash=sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354 \ --hash=sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2 \
--hash=sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6 --hash=sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0
# via # via
# mkdocs-material # mkdocs-material
# mkdocs-mermaid2-plugin # mkdocs-mermaid2-plugin
@@ -606,15 +606,14 @@ six==1.17.0 \
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
# via # via
# -c src/backend/requirements.txt # -c src/backend/requirements.txt
# jsbeautifier
# python-dateutil # python-dateutil
smmap==5.0.3 \ smmap==5.0.3 \
--hash=sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c \ --hash=sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c \
--hash=sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f --hash=sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f
# via gitdb # via gitdb
soupsieve==2.8.3 \ soupsieve==2.8.4 \
--hash=sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349 \ --hash=sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e \
--hash=sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95 --hash=sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65
# via beautifulsoup4 # via beautifulsoup4
super-collections==0.6.2 \ super-collections==0.6.2 \
--hash=sha256:0c8d8abacd9fad2c7c1c715f036c29f5db213f8cac65f24d45ecba12b4da187a \ --hash=sha256:0c8d8abacd9fad2c7c1c715f036c29f5db213f8cac65f24d45ecba12b4da187a \
@@ -671,7 +670,7 @@ watchdog==6.0.0 \
# via # via
# mkdocs # mkdocs
# properdocs # properdocs
wcmatch==10.1 \ wcmatch==11.0 \
--hash=sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a \ --hash=sha256:3a5977ace27e075eef67eb03d539563f1a19018b62881949a42932cf66926934 \
--hash=sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af --hash=sha256:55d95c2447789712774b198ceec72939e88b5618f1f8f0a9b605bf7740b63b96
# via mkdocs-include-markdown-plugin # via mkdocs-include-markdown-plugin
+5 -3
View File
@@ -31,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
@@ -511,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():
@@ -595,7 +597,7 @@ class BulkUpdateMixin(BulkOperationMixin):
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
@@ -692,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,14 +1,19 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """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/12393 v524 -> 2026-07-20 : https://github.com/inventree/InvenTree/pull/12393
- Adds "discount" field to order line items (and extra line items) - 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 v522 -> 2026-07-14 : https://github.com/inventree/InvenTree/pull/12388
- Adds "unique" field to the ParameterTemplate model - Adds "unique" field to the ParameterTemplate model
+1 -3
View File
@@ -947,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': _(
+93 -1
View File
@@ -6,6 +6,7 @@ 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
@@ -13,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):
@@ -569,6 +571,96 @@ 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): def test_parameter_uniqueness(self):
"""Test the uniqueness options which can be applied to a ParameterTemplate.""" """Test the uniqueness options which can be applied to a ParameterTemplate."""
from company.models import Company from company.models import Company
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+19 -6
View File
@@ -781,7 +781,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."""
@@ -1073,7 +1073,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."""
@@ -1117,7 +1120,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()
@@ -1787,7 +1792,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."""
@@ -1830,7 +1838,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()
@@ -2325,7 +2335,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."""
+1 -1
View File
@@ -3495,7 +3495,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."""
+179 -2
View File
@@ -900,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')
@@ -2245,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')
@@ -3458,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 ExtraLineTotalPriceTest(InvenTreeAPITestCase): class ExtraLineTotalPriceTest(InvenTreeAPITestCase):
"""Unit tests for the 'total_price' field on ExtraLine API endpoints. """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': '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')
+5 -4
View File
@@ -1038,12 +1038,13 @@ class PartSerializer(
initial_supplier = validated_data.pop('initial_supplier', None) initial_supplier = validated_data.pop('initial_supplier', None)
copy_category_parameters = validated_data.pop('copy_category_parameters', False) copy_category_parameters = validated_data.pop('copy_category_parameters', False)
instance = super().create(validated_data) # Additional data to apply to the serializer
extra_data = {}
# Save user information
if request := self.context.get('request'): if request := self.context.get('request'):
instance.creation_user = request.user extra_data['creation_user'] = request.user
instance.save()
instance = super().create({**validated_data, **extra_data})
# Copy data from original Part # Copy data from original Part
if duplicate: if duplicate:
+77 -2
View File
@@ -7,7 +7,7 @@ from random import randint
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import connection from django.db import connection
from django.test.utils import CaptureQueriesContext from django.test.utils import CaptureQueriesContext, override_settings
from django.urls import reverse from django.urls import reverse
import pytest import pytest
@@ -19,9 +19,15 @@ import company.models
import order.models import order.models
from build.status_codes import BuildStatus from build.status_codes import BuildStatus
from common.models import InvenTreeSetting, ParameterTemplate from common.models import InvenTreeSetting, ParameterTemplate
from common.settings import set_global_setting
from company.models import Company, SupplierPart from company.models import Company, SupplierPart
from InvenTree.config import get_testfolder_dir from InvenTree.config import get_testfolder_dir
from InvenTree.unit_test import InvenTreeAPIPerformanceTestCase, InvenTreeAPITestCase from InvenTree.unit_test import (
InvenTreeAPIPerformanceTestCase,
InvenTreeAPITestCase,
findOffloadedEvent,
findOffloadedTask,
)
from order.status_codes import PurchaseOrderStatusGroups from order.status_codes import PurchaseOrderStatusGroups
from part.models import ( from part.models import (
BomItem, BomItem,
@@ -1506,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."""
+1 -3
View File
@@ -37,9 +37,7 @@ class ReportSerializerBase(InvenTreeModelSerializer):
_('User must be authenticated to save report templates') _('User must be authenticated to save report templates')
) )
instance = super().save(**kwargs) instance = super().save(updated_by=user, **kwargs)
instance.updated_by = user
instance.save(increment_revision=False)
return instance return instance
+37 -2
View File
@@ -6,6 +6,7 @@ from datetime import datetime, timedelta
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.test.utils import override_settings
from django.urls import reverse from django.urls import reverse
import pytest import pytest
@@ -18,7 +19,11 @@ import order.models
import part.models import part.models
from common.models import InvenTreeCustomUserStateModel, InvenTreeSetting from common.models import InvenTreeCustomUserStateModel, InvenTreeSetting
from common.settings import set_global_setting from common.settings import set_global_setting
from InvenTree.unit_test import InvenTreeAPIPerformanceTestCase, InvenTreeAPITestCase from InvenTree.unit_test import (
InvenTreeAPIPerformanceTestCase,
InvenTreeAPITestCase,
findOffloadedEvent,
)
from part.models import Part, PartTestTemplate from part.models import Part, PartTestTemplate
from stock.models import ( from stock.models import (
StockItem, StockItem,
@@ -3695,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)
@@ -3740,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')
+32 -19
View File
@@ -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}
@@ -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
@@ -45,9 +45,12 @@ export function SecurityContent() {
const user = useUserState(); const user = useUserState();
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: ${error}`); 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.Control>
<Accordion.Panel> <Accordion.Panel>
<ErrorBoundary <ErrorBoundary
fallback={<DefaultFallback title={'API Table'} />} fallback={
<DefaultFallback title={'API Table'} error={errorMessage} />
}
onError={onError} onError={onError}
> >
<ApiTokenTable only_myself /> <ApiTokenTable only_myself />
@@ -390,7 +390,8 @@ export default function SystemSettings() {
keys={[ keys={[
'TRANSFERORDER_ENABLED', 'TRANSFERORDER_ENABLED',
'TRANSFERORDER_REFERENCE_PATTERN', 'TRANSFERORDER_REFERENCE_PATTERN',
'TRANSFERORDER_REQUIRE_RESPONSIBLE' 'TRANSFERORDER_REQUIRE_RESPONSIBLE',
'TRANSFERORDER_EDIT_COMPLETED_ORDERS'
]} ]}
/> />
) )
@@ -69,13 +69,11 @@ export default function TransferOrderDetail() {
const orderOpen: boolean = const orderOpen: boolean =
order.status != toStatus.COMPLETE && order.status != toStatus.CANCELLED; order.status != toStatus.COMPLETE && order.status != toStatus.CANCELLED;
return orderOpen; if (orderOpen) {
// TODO: does this setting make any sense for Transfer Orders??? return true;
// if (orderOpen) { } else {
// return true; return globalSettings.isSet('TRANSFERORDER_EDIT_COMPLETED_ORDERS');
// } else { }
// return globalSettings.isSet('TRANSFERORDER_EDIT_COMPLETED_ORDERS');
// }
}, [globalSettings, order.status, toStatus]); }, [globalSettings, order.status, toStatus]);
// for now, only permit editing allocations when line items can be edited // for now, only permit editing allocations when line items can be edited
@@ -184,6 +184,9 @@ export default function ExtraLineItemTable({
params: { params: {
order: orderId order: orderId
}, },
enableSelection: true,
enableBulkDelete: editable && user.hasDeleteRole(role),
afterBulkDelete: orderDetailRefresh,
defaultSortColumn: 'line', defaultSortColumn: 'line',
rowActions: rowActions, rowActions: rowActions,
tableActions: tableActions tableActions: tableActions
@@ -7,6 +7,7 @@ import {
DescriptionColumn, DescriptionColumn,
PartColumn PartColumn
} from '../../components/tables/ColumnRenderers'; } from '../../components/tables/ColumnRenderers';
import { renderPartStockCell } from '../../components/tables/PartStockCell';
import ParametricDataTable from '../general/ParametricDataTable'; import ParametricDataTable from '../general/ParametricDataTable';
import { PartTableFilters } from './PartTableFilters'; import { PartTableFilters } from './PartTableFilters';
@@ -33,7 +34,8 @@ export default function ParametricPartTable({
}, },
{ {
accessor: 'total_in_stock', 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