Merge commit 'fcbe4302b9fda7682b1a85e7d315b2d1545a16bf' into line-discount
This commit is contained in:
commit
4a14e3ba85
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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") }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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': _(
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 [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 />
|
||||
|
|
|
|||
|
|
@ -390,7 +390,8 @@ export default function SystemSettings() {
|
|||
keys={[
|
||||
'TRANSFERORDER_ENABLED',
|
||||
'TRANSFERORDER_REFERENCE_PATTERN',
|
||||
'TRANSFERORDER_REQUIRE_RESPONSIBLE'
|
||||
'TRANSFERORDER_REQUIRE_RESPONSIBLE',
|
||||
'TRANSFERORDER_EDIT_COMPLETED_ORDERS'
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue