From 766a8b41dbc451a645ad9ed2fdf1b2ea5820ee1a Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 13 Jul 2026 15:09:27 +1000 Subject: [PATCH] [refactor] Remove MPTT mixin from StockItem model (#12360) * Remove MPTT from StockItem model * cleanup stock fixture file * Remove tree rebuild code for StockItem * Adjust API * Update CHANGELOG and API version * remove tree references * Additional checks * Remove unused tests * Add data migration test * fix fk fields * Cleanup * Tweak unit test * Revert bulk-create in unit test * Don't rebuild StockItem models * Add bulk_create_and_fetch helper model - Overcome limitations of mysql * Add unit test for new bulk-create helper * Apply new helper to codebase * Adjust unit test * bug fix * Adjust query limits * Remove assert --- CHANGELOG.md | 2 + .../InvenTree/InvenTree/api_version.py | 7 +- src/backend/InvenTree/InvenTree/helpers_db.py | 66 +++++++ .../management/commands/rebuild_models.py | 10 - .../InvenTree/InvenTree/test_helpers_db.py | 163 ++++++++++++++++ src/backend/InvenTree/InvenTree/tests.py | 5 +- src/backend/InvenTree/build/test_api.py | 4 +- src/backend/InvenTree/build/test_build.py | 5 +- src/backend/InvenTree/order/models.py | 25 +-- src/backend/InvenTree/order/test_api.py | 14 +- .../InvenTree/order/test_sales_order.py | 15 +- src/backend/InvenTree/stock/api.py | 30 +-- .../InvenTree/stock/fixtures/stock.yaml | 116 ----------- .../migrations/0124_alter_mptt_fields.py | 34 ++++ .../migrations/0125_remove_mptt_fields.py | 41 ++++ src/backend/InvenTree/stock/models.py | 76 ++------ src/backend/InvenTree/stock/tasks.py | 61 +----- src/backend/InvenTree/stock/test_api.py | 38 +--- .../InvenTree/stock/test_migrations.py | 79 ++++++++ src/backend/InvenTree/stock/tests.py | 182 ------------------ src/frontend/src/pages/stock/StockDetail.tsx | 2 +- 21 files changed, 434 insertions(+), 541 deletions(-) create mode 100644 src/backend/InvenTree/InvenTree/helpers_db.py create mode 100644 src/backend/InvenTree/InvenTree/test_helpers_db.py create mode 100644 src/backend/InvenTree/stock/migrations/0124_alter_mptt_fields.py create mode 100644 src/backend/InvenTree/stock/migrations/0125_remove_mptt_fields.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f3da3da15b..087ec2f188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking Changes +- [#12360](https://github.com/inventree/InvenTree/pull/12360) removes the MPTT mixin from the StockItem model, and removes the self-referential tree structure from the database. This change was made to simplify the StockItem model and improve performance, as the MPTT tree structure was causing significant overhead in certain operations. Any external client applications which made use of the MPTT functionality will need to be updated to account for this change. - [#12320](https://github.com/inventree/InvenTree/pull/12320) changes the default behavior of the `invoke migrate` command. Now, it no longer generates new migrations by default. Instead, it will only apply existing migrations to the database. If you want to detect and generate new migrations, you must now explicitly use the `--detect` flag. This change was made to prevent accidental generation of migrations when running the command, which could lead to unexpected changes in the database schema. Additionally, `invoke update` will no longer result in new migrations being generated, and will only apply existing migrations to the database. This change was made to ensure that the update process is predictable and does not introduce unexpected changes to the database schema. - [#12223](https://github.com/inventree/InvenTree/pull/12223) removes support for python 3.11 and stops providing packages for Debian 11 and Ubuntu 20.04. ### Added +- [#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. - [#12341](https://github.com/inventree/InvenTree/pull/12341) adds support for importing internal part prices. - [#12295](https://github.com/inventree/InvenTree/pull/12295) adds "consumable" field to the Part model and API endpoints diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index d3b700a764..19d4c3b9a1 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,12 +1,15 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 520 +INVENTREE_API_VERSION = 521 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ -v520 -> 2026-07-11 : https://github.com/inventree/InvenTree/pull/xxxx +v521 -> 2026-07-12 : https://github.com/inventree/InvenTree/pull/12360 + - Removes the MPTT mixin from the StockItem model, and removes the self-referential tree structure from the database. + +v520 -> 2026-07-11 : https://github.com/inventree/InvenTree/pull/12310 - Adds new "disassemble" API endpoint for stock items - Allows a stock item to be broken down into component parts, based on its Bill of Materials diff --git a/src/backend/InvenTree/InvenTree/helpers_db.py b/src/backend/InvenTree/InvenTree/helpers_db.py new file mode 100644 index 0000000000..1dfaeea581 --- /dev/null +++ b/src/backend/InvenTree/InvenTree/helpers_db.py @@ -0,0 +1,66 @@ +"""Database helper functions for InvenTree.""" + +import uuid +from typing import Optional + +from django.db import transaction +from django.db.models import QuerySet + + +@transaction.atomic +def bulk_create_and_fetch( + model, items, id_field: str = 'pk', filters: Optional[dict] = None +) -> QuerySet: + """Bulk create items in the database, and return a queryset of the created items. + + Arguments: + model: The Django model class to create instances of. + items: A list of dictionaries containing the data for each item to be created. + id_field: The name of the field to use as the unique identifier for the created items. + filters: Optional dictionary of filters to apply when fetching the created items. + + Returns: + A Django QuerySet containing the created items. + + This helper method is required because the Django bulk_create() method + does not guarantee that the ID values of the created items will be populated in the returned objects. + In particular, MySQL does not support returning the ID values of bulk created items. + + So, we provide temporary metadata to the created items, + which can be used to fetch the created items from the database. + + Assumptions: + - The provided model type has a "metadata" attribute which can be overloaded for this purpose + - No "metadata" is provided in the input items, as this will be overwritten by the method + - The model type has an incrementing ID field (default: "pk") + + """ + bulk_create_id = uuid.uuid4().hex + + # Generate temporary metadata for bulk fetching + metadata = {'bulk_create_id': bulk_create_id} + + lookup_filters = dict(filters) if filters else {} + + lookup_filters['metadata__bulk_create_id'] = bulk_create_id + + if id_field: + # Find the "most recent" item in the database, to set a search floor + if instance := model.objects.order_by(f'-{id_field}').first(): + lookup_filters[f'{id_field}__gt'] = getattr(instance, id_field) + + # Overwrite the metadata values + for item in items: + item.metadata = metadata + + model.objects.bulk_create(items, batch_size=500) + + instances = model.objects.filter(**lookup_filters) + + pks = list(instances.values_list(id_field or 'pk', flat=True)) + + # Override the metadata values to remove the temporary bulk_create_id + instances.update(metadata=None) + + # Fetch the newly created items (by primary key, as the metadata filter no longer matches) + return model.objects.filter(**{f'{id_field or "pk"}__in': pks}) diff --git a/src/backend/InvenTree/InvenTree/management/commands/rebuild_models.py b/src/backend/InvenTree/InvenTree/management/commands/rebuild_models.py index c1d35f879b..33a21b5d65 100644 --- a/src/backend/InvenTree/InvenTree/management/commands/rebuild_models.py +++ b/src/backend/InvenTree/InvenTree/management/commands/rebuild_models.py @@ -43,16 +43,6 @@ class Command(BaseCommand): except Exception: logger.info('Error rebuilding PartCategory objects') - # StockItem model - try: - logger.info('Rebuilding StockItem objects') - - from stock.models import StockItem - - StockItem.objects.rebuild() - except Exception: - logger.info('Error rebuilding StockItem objects') - # StockLocation model try: logger.info('Rebuilding StockLocation objects') diff --git a/src/backend/InvenTree/InvenTree/test_helpers_db.py b/src/backend/InvenTree/InvenTree/test_helpers_db.py new file mode 100644 index 0000000000..6a899f0bc2 --- /dev/null +++ b/src/backend/InvenTree/InvenTree/test_helpers_db.py @@ -0,0 +1,163 @@ +"""Functional tests for the InvenTree.helpers_db module.""" + +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext + +from company.models import Company, Contact +from InvenTree.helpers_db import bulk_create_and_fetch +from order.models import PurchaseOrder +from part.models import Part +from stock.models import StockItem + + +class BulkCreateAndFetchTest(TestCase): + """Tests for the bulk_create_and_fetch helper function.""" + + def test_basic(self): + """Created items are returned with populated, unique primary keys.""" + company = Company.objects.create(name='ACME') + + items = [Contact(company=company, name=f'Contact {idx}') for idx in range(10)] + + result = bulk_create_and_fetch(Contact, items) + + self.assertEqual(result.count(), 10) + + pks = [c.pk for c in result] + + # Every item has a valid, unique primary key + self.assertTrue(all(pk is not None for pk in pks)) + self.assertEqual(len(pks), len(set(pks))) + + # Returned items exactly match what is actually in the database + db_pks = set( + Contact.objects.filter(company=company).values_list('pk', flat=True) + ) + self.assertEqual(set(pks), db_pks) + + # Temporary bulk-create metadata should have been cleared again + for contact in Contact.objects.filter(pk__in=pks): + self.assertIsNone(contact.metadata) + + def test_with_filters(self): + """Additional filters correctly scope the returned queryset.""" + company_a = Company.objects.create(name='Company A') + company_b = Company.objects.create(name='Company B') + + items_a = [Contact(company=company_a, name=f'A{i}') for i in range(3)] + items_b = [Contact(company=company_b, name=f'B{i}') for i in range(4)] + + result_a = bulk_create_and_fetch( + Contact, items_a, filters={'company': company_a} + ) + result_b = bulk_create_and_fetch( + Contact, items_b, filters={'company': company_b} + ) + + self.assertEqual(result_a.count(), 3) + self.assertEqual(result_b.count(), 4) + + self.assertTrue(all(c.company_id == company_a.pk for c in result_a)) + self.assertTrue(all(c.company_id == company_b.pk for c in result_b)) + + def test_ignores_pre_existing_rows(self): + """The 'search floor' should exclude pre-existing rows in the table.""" + company = Company.objects.create(name='ACME') + + # Pre-existing item, *not* created via the helper + Contact.objects.create(company=company, name='Existing') + + items = [Contact(company=company, name=f'New {i}') for i in range(2)] + result = bulk_create_and_fetch(Contact, items) + + self.assertEqual(result.count(), 2) + self.assertNotIn('Existing', [c.name for c in result]) + + def test_empty_list(self): + """Calling with an empty list of items should not raise, and return no items.""" + result = bulk_create_and_fetch(Contact, []) + self.assertEqual(result.count(), 0) + + def test_does_not_mutate_caller_filters(self): + """The caller-provided 'filters' dict should not be mutated as a side effect.""" + company = Company.objects.create(name='ACME') + filters = {'company': company} + + items = [Contact(company=company, name='Contact')] + bulk_create_and_fetch(Contact, items, filters=filters) + + def test_purchase_order(self): + """The helper works for the PurchaseOrder model.""" + supplier = Company.objects.create(name='Supplier Co', is_supplier=True) + + references = [f'PO-{idx:04d}' for idx in range(10)] + + items = [ + PurchaseOrder(supplier=supplier, reference=ref, description=f'Order {ref}') + for ref in references + ] + + result = bulk_create_and_fetch(PurchaseOrder, items) + + self.assertEqual(result.count(), 10) + + pks = [o.pk for o in result] + self.assertTrue(all(pk is not None for pk in pks)) + self.assertEqual(len(pks), len(set(pks))) + + self.assertEqual( + set(result.values_list('reference', flat=True)), set(references) + ) + + for order in PurchaseOrder.objects.filter(pk__in=pks): + self.assertIsNone(order.metadata) + + def test_stock_item(self): + """The helper works for the StockItem model.""" + part = Part.objects.create(name='Widget', description='A widget') + + items = [StockItem(part=part, quantity=idx + 1) for idx in range(10)] + + result = bulk_create_and_fetch(StockItem, items) + + self.assertEqual(result.count(), 10) + + pks = [si.pk for si in result] + self.assertTrue(all(pk is not None for pk in pks)) + self.assertEqual(len(pks), len(set(pks))) + + self.assertEqual( + sorted(result.values_list('quantity', flat=True)), + sorted(idx + 1 for idx in range(10)), + ) + + for stock_item in StockItem.objects.filter(pk__in=pks): + self.assertIsNone(stock_item.metadata) + + def _create_and_count_queries(self, n: int) -> int: + """Bulk-create 'n' Contact items, and return the number of queries used.""" + company = Company.objects.create(name=f'Company {n}') + + items = [Contact(company=company, name=f'Contact {i}') for i in range(n)] + + with CaptureQueriesContext(connection) as ctx: + result = bulk_create_and_fetch(Contact, items) + + self.assertEqual(result.count(), n) + + MAX_QUERIES = 25 if n > 100 else 10 + + self.assertLess(len(ctx.captured_queries), MAX_QUERIES) + + return len(ctx.captured_queries) + + def test_query_count_is_constant(self): + """The number of queries used should not depend on the number of created items.""" + small_query_count = self._create_and_count_queries(5) + large_query_count = self._create_and_count_queries(100) + + self.assertEqual(small_query_count, large_query_count) + + # Perform a very large bulk-create - this will be batched + self._create_and_count_queries(2000) diff --git a/src/backend/InvenTree/InvenTree/tests.py b/src/backend/InvenTree/InvenTree/tests.py index b85ecf250a..f8ef27a75e 100644 --- a/src/backend/InvenTree/InvenTree/tests.py +++ b/src/backend/InvenTree/InvenTree/tests.py @@ -104,10 +104,9 @@ class TreeFixtureTest(TestCase): self.run_tree_test(Build) def test_stock(self): - """Test MPTT tree structure for Stock model.""" - from stock.models import StockItem, StockLocation + """Test MPTT tree structure for StockLocation model.""" + from stock.models import StockLocation - self.run_tree_test(StockItem) self.run_tree_test(StockLocation) diff --git a/src/backend/InvenTree/build/test_api.py b/src/backend/InvenTree/build/test_api.py index a4a35c1a5e..2b9c6d9e3e 100644 --- a/src/backend/InvenTree/build/test_api.py +++ b/src/backend/InvenTree/build/test_api.py @@ -837,9 +837,7 @@ class BuildAllocationTest(BuildAPITest): ) # Test a fractional quantity when the *available* quantity is less than 1 - si = StockItem.objects.create( - part=si.part, quantity=0.3159, tree_id=0, level=0, lft=0, rght=0 - ) + si = StockItem.objects.create(part=si.part, quantity=0.3159) self.post( self.url, diff --git a/src/backend/InvenTree/build/test_build.py b/src/backend/InvenTree/build/test_build.py index f0ce20a72c..b1cbfd3d09 100644 --- a/src/backend/InvenTree/build/test_build.py +++ b/src/backend/InvenTree/build/test_build.py @@ -527,15 +527,14 @@ class BuildTest(BuildTestBase): # Return a partial quantity of each item to stock for item in consumed_items: - self.assertEqual(item.get_descendant_count(), 0) q = item.quantity self.assertGreater(item.quantity, 1) item.return_to_stock(location, merge=False, quantity=1) item.refresh_from_db() self.assertEqual(item.quantity, q - 1) - self.assertEqual(item.get_descendant_count(), 1) + self.assertEqual(item.children.count(), 1) self.assertFalse(item.is_in_stock()) - child = item.get_descendants().first() + child = item.children.first() self.assertTrue(child.is_in_stock()) def test_change_part(self): diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 7f8b5cb2b5..50a0d235b9 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -44,6 +44,7 @@ from InvenTree.fields import ( RoundingDecimalField, ) from InvenTree.helpers import decimal2string, pui_url +from InvenTree.helpers_db import bulk_create_and_fetch from InvenTree.helpers_model import notify_responsible from order.events import ( PurchaseOrderEvents, @@ -1224,15 +1225,7 @@ class PurchaseOrder(TotalPriceMixin, Order): stock_items.append(new_item) else: - new_item = stock.models.StockItem( - **stock_data, - serial='', - tree_id=stock.models.StockItem.getNextTreeID(), - parent=None, - level=0, - lft=1, - rght=2, - ) + new_item = stock.models.StockItem(**stock_data, serial='', parent=None) new_item.set_status(status, custom_values=custom_stock_status_values) @@ -1250,18 +1243,10 @@ class PurchaseOrder(TotalPriceMixin, Order): # run validation for items plugin.validate_model_instance item.run_plugin_validation() - stock.models.StockItem.objects.bulk_create( - bulk_create_items, batch_size=250 - ) + # Bulk create the stock items and fetch the newly created instances + new_items = bulk_create_and_fetch(stock.models.StockItem, bulk_create_items) - # Fetch them back again - tree_ids = [item.tree_id for item in bulk_create_items] - - created_items = stock.models.StockItem.objects.filter( - tree_id__in=tree_ids, level=0, lft=1, rght=2, purchase_order=self - ).prefetch_related('location') - - stock_items.extend(created_items) + stock_items.extend(new_items) # Generate a new tracking entry for each stock item for item in stock_items: diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index d0f17398f9..de4805bef1 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -34,7 +34,7 @@ from order.status_codes import ( ) from part.models import Part from stock.models import StockItem, StockLocation, StockSortOrder -from stock.status_codes import StockStatus +from stock.status_codes import StockHistoryCode, StockStatus from users.models import Owner @@ -1416,12 +1416,24 @@ class PurchaseOrderReceiveTest(OrderTest): # Check for expected response self.assertEqual(len(response), N_LINES) + + # Check that the expected number of stock items has been created self.assertEqual(N_ITEMS + N_LINES, StockItem.objects.count()) for item in response: self.assertEqual(item['purchase_order'], po.pk) self.assertEqual(item['status'], StockStatus.QUARANTINED) + stock_item = StockItem.objects.get(pk=item['pk']) + # Check that the item has tracking entries + self.assertEqual(stock_item.tracking_info.count(), 1) + entry = stock_item.tracking_info.first() + self.assertEqual(entry.deltas['quantity'], stock_item.quantity) + self.assertEqual(entry.user, self.user) + self.assertEqual( + entry.tracking_type, StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER + ) + # Check that the order has been completed po.refresh_from_db() self.assertEqual(po.status, PurchaseOrderStatus.COMPLETE) diff --git a/src/backend/InvenTree/order/test_sales_order.py b/src/backend/InvenTree/order/test_sales_order.py index aafad46e1f..7837c58098 100644 --- a/src/backend/InvenTree/order/test_sales_order.py +++ b/src/backend/InvenTree/order/test_sales_order.py @@ -408,21 +408,8 @@ class SalesOrderTest(InvenTreeTestCase): allocations = [] stock_items = [] - tree_id = StockItem.objects.all().order_by('-tree_id').first().tree_id - for idx in range(N_ITEMS): - tree_id += 1 - - stock_items.append( - StockItem( - part=part, - quantity=1 + idx % 5, - level=0, - lft=0, - rght=0, - tree_id=tree_id, - ) - ) + stock_items.append(StockItem(part=part, quantity=1 + idx % 5)) StockItem.objects.bulk_create(stock_items) diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index 85c27a304e..529f7a4f13 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -582,6 +582,7 @@ class StockFilter(FilterSet): # Simple filter filters fields = [ 'supplier_part', + 'parent', 'belongs_to', 'build', 'customer', @@ -910,15 +911,6 @@ class StockFilter(FilterSet): return queryset.exclude(purchase_price=None) return queryset.filter(purchase_price=None) - ancestor = rest_filters.ModelChoiceFilter( - label='Ancestor', queryset=StockItem.objects.all(), method='filter_ancestor' - ) - - @extend_schema_field(OpenApiTypes.INT) - def filter_ancestor(self, queryset, name, ancestor): - """Filter based on ancestor stock item.""" - return queryset.filter(parent__in=ancestor.get_descendants(include_self=True)) - category = rest_filters.ModelChoiceFilter( label=_('Category'), queryset=PartCategory.objects.all(), @@ -1027,26 +1019,6 @@ class StockFilter(FilterSet): else: return queryset.exclude(stale_filter) - exclude_tree = rest_filters.NumberFilter( - method='filter_exclude_tree', - label=_('Exclude Tree'), - help_text=_( - 'Provide a StockItem PK to exclude that item and all its descendants' - ), - ) - - def filter_exclude_tree(self, queryset, name, value): - """Exclude a StockItem and all of its descendants from the queryset.""" - try: - root = StockItem.objects.get(pk=value) - pks_to_exclude = [ - item.pk for item in root.get_descendants(include_self=True) - ] - return queryset.exclude(pk__in=pks_to_exclude) - except (ValueError, StockItem.DoesNotExist): - # If the value is invalid or the object doesn't exist, do nothing. - return queryset - cascade = rest_filters.BooleanFilter( method='filter_cascade', label=_('Cascade Locations'), diff --git a/src/backend/InvenTree/stock/fixtures/stock.yaml b/src/backend/InvenTree/stock/fixtures/stock.yaml index 8fd9fd97c0..6181ccaf1c 100644 --- a/src/backend/InvenTree/stock/fixtures/stock.yaml +++ b/src/backend/InvenTree/stock/fixtures/stock.yaml @@ -10,10 +10,6 @@ quantity: 4000 purchase_price: 123 purchase_price_currency: AUD - tree_id: 20 - level: 0 - lft: 1 - rght: 2 # 5,000 screws in the bathroom - model: stock.stockitem @@ -22,10 +18,6 @@ part: 1 location: 2 quantity: 5000 - tree_id: 21 - level: 0 - lft: 1 - rght: 2 # Capacitor C_22N_0805 in 'Office' - model: stock.stockitem @@ -34,10 +26,6 @@ part: 5 location: 4 quantity: 666 - tree_id: 16 - level: 0 - lft: 1 - rght: 2 # 1234 2K2 resistors in 'Drawer_1' - model: stock.stockitem @@ -46,10 +34,6 @@ part: 3 location: 5 quantity: 1234 - tree_id: 22 - level: 0 - lft: 1 - rght: 2 # Some widgets in drawer 3 - model: stock.stockitem @@ -60,10 +44,6 @@ location: 7 quantity: 10 delete_on_deplete: False - tree_id: 28 - level: 0 - lft: 1 - rght: 2 - model: stock.stockitem pk: 101 @@ -72,10 +52,6 @@ batch: "B2345" location: 7 quantity: 5 - tree_id: 27 - level: 0 - lft: 1 - rght: 2 - model: stock.stockitem pk: 102 @@ -84,10 +60,6 @@ batch: 'BCDE' location: 7 quantity: 0 - tree_id: 26 - level: 0 - lft: 1 - rght: 2 - model: stock.stockitem pk: 105 @@ -97,10 +69,6 @@ quantity: 1 serial: 1000 serial_int: 1000 - tree_id: 29 - level: 0 - lft: 1 - rght: 2 # Stock items for template / variant parts - model: stock.stockitem @@ -110,10 +78,6 @@ location: 7 quantity: 5 batch: "BBAAA" - tree_id: 2 - level: 0 - lft: 1 - rght: 2 - model: stock.stockitem pk: 501 @@ -123,10 +87,6 @@ quantity: 1 serial: 1 serial_int: 1 - tree_id: 3 - level: 0 - lft: 1 - rght: 2 - model: stock.stockitem pk: 502 @@ -136,10 +96,6 @@ quantity: 1 serial: 2 serial_int: 2 - tree_id: 4 - level: 0 - lft: 1 - rght: 2 - model: stock.stockitem pk: 503 @@ -149,10 +105,6 @@ quantity: 1 serial: 3 serial_int: 3 - tree_id: 5 - level: 0 - lft: 1 - rght: 2 - model: stock.stockitem pk: 504 @@ -162,10 +114,6 @@ quantity: 1 serial: 4 serial_int: 4 - tree_id: 6 - level: 0 - lft: 1 - rght: 2 - model: stock.stockitem pk: 505 @@ -175,10 +123,6 @@ quantity: 1 serial: 5 serial_int: 5 - tree_id: 1 - level: 0 - lft: 1 - rght: 2 - model: stock.stockitem pk: 510 @@ -188,10 +132,6 @@ quantity: 1 serial: 10 serial_int: 10 - tree_id: 24 - level: 0 - lft: 1 - rght: 2 - model: stock.stockitem pk: 511 @@ -201,10 +141,6 @@ quantity: 1 serial: 11 serial_int: 11 - tree_id: 23 - level: 0 - lft: 1 - rght: 2 - model: stock.stockitem pk: 512 @@ -214,10 +150,6 @@ quantity: 1 serial: 12 serial_int: 12 - tree_id: 25 - level: 0 - lft: 1 - rght: 2 - model: stock.stockitem pk: 520 @@ -229,10 +161,6 @@ serial_int: 20 expiry_date: "1990-10-10" barcode_hash: 9e5ae7fc20568ed4814c10967bba8b65 - tree_id: 18 - level: 0 - lft: 1 - rght: 2 - model: stock.stockitem pk: 521 @@ -244,10 +172,6 @@ serial_int: 21 status: 60 barcode_hash: 1be0dfa925825c5c6c79301449e50c2d - tree_id: 17 - level: 0 - lft: 1 - rght: 2 - model: stock.stockitem pk: 522 @@ -259,10 +183,6 @@ serial_int: 22 expiry_date: "1990-10-10" status: 70 - tree_id: 19 - level: 0 - lft: 1 - rght: 2 belongs_to: 105 # Multiple stock items for "Bob" (PK 100) @@ -272,10 +192,6 @@ part: 100 location: 1 quantity: 10 - tree_id: 9 - level: 0 - lft: 1 - rght: 4 - model: stock.stockitem pk: 1001 @@ -284,10 +200,6 @@ parent: 1000 location: 1 quantity: 11 - tree_id: 9 - level: 1 - lft: 2 - rght: 3 - model: stock.stockitem pk: 1002 @@ -295,10 +207,6 @@ part: 100 location: 1 quantity: 12 - tree_id: 8 - level: 0 - lft: 1 - rght: 2 - model: stock.stockitem pk: 1003 @@ -306,10 +214,6 @@ part: 100 location: 1 quantity: 13 - tree_id: 15 - level: 0 - lft: 1 - rght: 2 - model: stock.stockitem pk: 1004 @@ -317,10 +221,6 @@ part: 100 location: 1 quantity: 14 - tree_id: 7 - level: 0 - lft: 1 - rght: 2 - model: stock.stockitem pk: 1005 @@ -328,10 +228,6 @@ part: 100 location: 1 quantity: 15 - tree_id: 13 - level: 0 - lft: 1 - rght: 2 - model: stock.stockitem pk: 1006 @@ -339,10 +235,6 @@ part: 100 location: 1 quantity: 16 - tree_id: 12 - level: 0 - lft: 1 - rght: 2 - model: stock.stockitem pk: 1007 @@ -350,10 +242,6 @@ part: 100 location: 7 quantity: 17 - tree_id: 11 - level: 0 - lft: 1 - rght: 2 - model: stock.stockitem pk: 1008 @@ -361,7 +249,3 @@ part: 100 location: 7 quantity: 18 - tree_id: 10 - level: 0 - lft: 1 - rght: 2 diff --git a/src/backend/InvenTree/stock/migrations/0124_alter_mptt_fields.py b/src/backend/InvenTree/stock/migrations/0124_alter_mptt_fields.py new file mode 100644 index 0000000000..5b29e1a58c --- /dev/null +++ b/src/backend/InvenTree/stock/migrations/0124_alter_mptt_fields.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.15 on 2026-07-11 03:34 + +from django.db import migrations, models + + + +class Migration(migrations.Migration): + + dependencies = [ + ("stock", "0123_remove_stockitem_review_needed"), + ] + + operations = [ + migrations.AlterField( + model_name="stockitem", + name="level", + field=models.PositiveIntegerField(default=0), + ), + migrations.AlterField( + model_name="stockitem", + name="lft", + field=models.PositiveIntegerField(default=0), + ), + migrations.AlterField( + model_name="stockitem", + name="rght", + field=models.PositiveIntegerField(default=0), + ), + migrations.AlterField( + model_name="stockitem", + name="tree_id", + field=models.PositiveIntegerField(db_index=True, default=0), + ), + ] diff --git a/src/backend/InvenTree/stock/migrations/0125_remove_mptt_fields.py b/src/backend/InvenTree/stock/migrations/0125_remove_mptt_fields.py new file mode 100644 index 0000000000..fdb3559da9 --- /dev/null +++ b/src/backend/InvenTree/stock/migrations/0125_remove_mptt_fields.py @@ -0,0 +1,41 @@ +# Generated by Django 5.2.15 on 2026-07-11 03:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("stock", "0124_alter_mptt_fields"), + ] + + operations = [ + migrations.RemoveField( + model_name="stockitem", + name="level", + ), + migrations.RemoveField( + model_name="stockitem", + name="lft", + ), + migrations.RemoveField( + model_name="stockitem", + name="rght", + ), + migrations.RemoveField( + model_name="stockitem", + name="tree_id", + ), + migrations.AlterField( + model_name="stockitem", + name="parent", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=models.deletion.SET_NULL, + related_name="children", + to="stock.stockitem", + verbose_name="Parent Stock Item", + ), + ), + ] diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 43534d57e9..edbda1142f 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -35,7 +35,6 @@ import InvenTree.ready import InvenTree.tasks import order.models import report.mixins -import stock.tasks from common.icons import validate_icon from common.settings import get_global_setting from company import models as CompanyModels @@ -422,7 +421,6 @@ STOCK_SORT_DEFAULT = StockSortOrder.DATE_OLDEST class StockItem( - InvenTree.models.PluginValidationMixin, InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, @@ -431,7 +429,7 @@ class StockItem( report.mixins.InvenTreeReportMixin, common.models.MetaMixin, InvenTree.models.MetadataMixin, - InvenTree.models.InvenTreeTree, + InvenTree.models.InvenTreeModel, ): """A StockItem object represents a quantity of physical instances of a part. @@ -567,15 +565,19 @@ class StockItem( """Return API url.""" return reverse('api-stock-list') - def api_instance_filters(self): - """Custom API instance filters.""" - return {'parent': {'exclude_tree': self.pk}} - @classmethod def barcode_model_type_code(cls): """Return the associated barcode model type code for this model.""" return 'SI' + def get_children(self): + """Return a queryset of all StockItem objects which are children of this StockItem. + + As the StockItem model is no longer a MPTT model, + this function is implemented manually + """ + return StockItem.objects.filter(parent=self).exclude(pk=self.pk) + def get_test_keys(self, include_installed=True): """Construct a flattened list of test 'keys' for this StockItem.""" keys = [] @@ -709,19 +711,9 @@ class StockItem( raise ValidationError({'part': _('Part must be specified')}) part = data['part'] - parent = kwargs.pop('parent', None) or data.get('parent') - tree_id = kwargs.pop('tree_id', StockItem.getNextTreeID()) - if parent: - # Override with parent's tree_id if provided - tree_id = parent.tree_id - - # Pre-calculate MPTT fields - data['parent'] = parent if parent else None - data['level'] = parent.level + 1 if parent else 0 - data['lft'] = 0 if parent else 1 - data['rght'] = 0 if parent else 2 + data['parent'] = parent # Force single quantity for each item data['quantity'] = 1 @@ -734,27 +726,12 @@ class StockItem( else: data['serial_int'] = 0 - data['tree_id'] = tree_id - - if not parent: - # No parent, this is a top-level item, so increment the tree_id - # This is because each new item is a "top-level" node in the StockItem tree - tree_id += 1 - # Construct a new StockItem from the provided dict items.append(StockItem(**data)) # Create the StockItem objects in bulk StockItem.objects.bulk_create(items, batch_size=250) - # We will need to rebuild the stock item tree manually, due to the bulk_create operation - if parent and parent.tree_id: - # Rebuild the tree structure for this StockItem tree - logger.info( - 'Rebuilding StockItem tree structure for tree_id: %s', parent.tree_id - ) - stock.tasks.rebuild_stock_item_tree(parent.tree_id) - # Fetch the new StockItem objects from the database items = StockItem.objects.filter(part=part, serial__in=serials) @@ -1064,10 +1041,10 @@ class StockItem( return self.part.full_name # Note: When a StockItem is deleted, a pre_delete signal handles the parent/child relationship - parent = TreeForeignKey( - 'self', + parent = models.ForeignKey( + 'stock.StockItem', verbose_name=_('Parent Stock Item'), - on_delete=models.DO_NOTHING, + on_delete=models.SET_NULL, blank=True, null=True, related_name='children', @@ -2029,7 +2006,6 @@ class StockItem( new_item.set_status(status, custom_values=custom_status_values) # Ensure the tree structure is observed - new_item.tree_id = None new_item.save(add_note=False) deltas = {'stockitem': self.pk, 'quantity': float(line_quantity)} @@ -2078,9 +2054,6 @@ class StockItem( StockHistoryCode.DISASSEMBLED, user, notes=notes, deltas=deltas ) - # Rebuild the stock tree for this item - stock.tasks.rebuild_stock_item_tree(self.tree_id) - trigger_event(StockEvents.ITEM_DISASSEMBLED, id=self.pk) return items @@ -2304,7 +2277,6 @@ class StockItem( # Set the parent ID correctly data['parent'] = self - data['tree_id'] = self.tree_id # Generate a new serial number for each item items = StockItem._create_serial_numbers(serials, **data) @@ -2527,9 +2499,6 @@ class StockItem( if len(other_items) == 0: return - # Keep track of the tree IDs that are being merged - tree_ids = {self.tree_id} - user = kwargs.get('user') location = kwargs.get('location', self.location) notes = kwargs.get('notes') or '' @@ -2553,8 +2522,6 @@ class StockItem( merged_quantity = Decimal(0) for other in other_items: - tree_ids.add(other.tree_id) - merged_quantity += other.quantity self.quantity += other.quantity @@ -2626,19 +2593,6 @@ class StockItem( self.save() - # Rebuild stock trees as required - rebuild_result = True - for tree_id in tree_ids: - if not stock.tasks.rebuild_stock_item_tree(tree_id, rebuild_on_fail=False): - rebuild_result = False - - if not rebuild_result: - # If the rebuild failed, offload the task to a background worker - logger.warning( - 'Failed to rebuild stock item tree during merge_stock_items operation, offloading task.' - ) - InvenTree.tasks.offload_task(stock.tasks.rebuild_stock_items, group='stock') - @transaction.atomic def splitStock(self, quantity, location=None, user=None, **kwargs): """Split this stock item into two items, in the same location. @@ -2711,7 +2665,6 @@ class StockItem( # Update the new stock item to ensure the tree structure is observed new_stock.parent = self - new_stock.tree_id = None # Create 'deltas' dict to record changes for tracking deltas = {'stockitem': self.pk} @@ -2759,9 +2712,6 @@ class StockItem( record_tracking=record_tracking, ) - # Rebuild the tree for this parent item - stock.tasks.rebuild_stock_item_tree(self.tree_id) - # Attempt to reload the new item from the database try: new_stock.refresh_from_db() diff --git a/src/backend/InvenTree/stock/tasks.py b/src/backend/InvenTree/stock/tasks.py index d8b647152f..d56fed9630 100644 --- a/src/backend/InvenTree/stock/tasks.py +++ b/src/backend/InvenTree/stock/tasks.py @@ -6,71 +6,12 @@ import structlog from opentelemetry import trace from common.settings import get_global_setting -from InvenTree.tasks import ScheduledTask, offload_task, scheduled_task +from InvenTree.tasks import ScheduledTask, scheduled_task tracer = trace.get_tracer(__name__) logger = structlog.get_logger('inventree') -@tracer.start_as_current_span('rebuild_stock_items') -def rebuild_stock_items(): - """Rebuild the entire StockItem tree structure. - - This may be necessary if the tree structure has become corrupted or inconsistent. - """ - from InvenTree.exceptions import log_error - from InvenTree.sentry import report_exception - from stock.models import StockItem - - logger.info('Rebuilding StockItem tree structure') - - try: - StockItem.objects.rebuild() - except Exception as e: - # This is a critical error, explicitly report to sentry - report_exception(e) - - log_error('rebuild_stock_items') - logger.exception('Failed to rebuild StockItem tree: %s', e) - - -def rebuild_stock_item_tree(tree_id: int, rebuild_on_fail: bool = True) -> bool: - """Rebuild the stock tree structure. - - Arguments: - tree_id (int): The ID of the StockItem tree to rebuild. - rebuild_on_fail (bool): If True, will attempt to rebuild the entire StockItem tree if the partial rebuild fails. - - Returns: - bool: True if the partial tree rebuild was successful, False otherwise. - - - If the rebuild fails, schedule a rebuild of the entire StockItem tree. - """ - from InvenTree.exceptions import log_error - from InvenTree.sentry import report_exception - from stock.models import StockItem - - if tree_id: - try: - StockItem.objects.partial_rebuild(tree_id) - logger.info('Rebuilt StockItem tree for tree_id: %s', tree_id) - return True - except Exception as e: - # This is a critical error, explicitly report to sentry - report_exception(e) - - log_error('rebuild_stock_item_tree') - logger.warning('Failed to rebuild StockItem tree for tree_id: %s', tree_id) - # If the partial rebuild fails, rebuild the entire tree - if rebuild_on_fail: - offload_task(rebuild_stock_items, group='stock') - return False - else: - # No tree_id provided, so rebuild the entire tree - StockItem.objects.rebuild() - return True - - @tracer.start_as_current_span('delete_old_stock_tracking') @scheduled_task(ScheduledTask.DAILY) def delete_old_stock_tracking(): diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index 8347a75b6f..e869f438ea 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -617,17 +617,7 @@ class StockItemListTest(StockAPITestCase): item.delete() for idx in range(1000): - items.append( - StockItem( - part=part, - location=location, - quantity=idx % 10, - level=0, - lft=0, - rght=0, - tree_id=0, - ) - ) + items.append(StockItem(part=part, location=location, quantity=idx % 10)) StockItem.objects.bulk_create(items, batch_size=250) @@ -738,13 +728,6 @@ class StockItemListTest(StockAPITestCase): response = self.get_stock(location=7) self.assertEqual(len(response), 18) - def test_filter_by_exclude_tree(self): - """Filter StockItem by excluding a StockItem tree.""" - response = self.get_stock(exclude_tree=1000) - for item in response: - self.assertNotEqual(item['pk'], 1000) - self.assertNotEqual(item['parent'], 1000) - def test_filter_by_depleted(self): """Filter StockItem by depleted status.""" response = self.get_stock(depleted=1) @@ -1044,15 +1027,7 @@ class StockItemListTest(StockAPITestCase): part = parts[idx % N_PARTS] location = locations[idx % N_LOCATIONS] - item = StockItem( - part=part, - location=location, - quantity=10, - level=0, - tree_id=0, - lft=0, - rght=0, - ) + item = StockItem(part=part, location=location, quantity=10) stock_items.append(item) idx += 1 @@ -1168,8 +1143,7 @@ class StockItemListTest(StockAPITestCase): prt = Part.objects.first() StockItem.objects.bulk_create([ - StockItem(part=prt, quantity=1, level=0, tree_id=0, lft=0, rght=0) - for _ in range(100) + StockItem(part=prt, quantity=1) for _ in range(100) ]) # List *all* stock items @@ -1249,7 +1223,7 @@ class StockItemListTest(StockAPITestCase): parent_item.refresh_from_db() # Check that the parent item has 5 child items - self.assertEqual(parent_item.get_descendants(include_self=False).count(), 5) + self.assertEqual(parent_item.get_children().count(), 5) self.assertEqual(my_part.stock_items.count(), 6) # Fetch stock list via API @@ -1454,10 +1428,6 @@ class CustomStockItemStatusTest(StockAPITestCase): StockItem( part=part, quantity=1, - level=0, - tree_id=0, - lft=0, - rght=0, status=custom_statuses[i % 10].logical_key, status_custom_key=custom_statuses[i % 10].key, ) diff --git a/src/backend/InvenTree/stock/test_migrations.py b/src/backend/InvenTree/stock/test_migrations.py index c26b881f42..510df16972 100644 --- a/src/backend/InvenTree/stock/test_migrations.py +++ b/src/backend/InvenTree/stock/test_migrations.py @@ -584,3 +584,82 @@ class TestCreationDateMigration(MigratorTestCase): # Scenario 6: updated=NULL, no stocktake, no tracking → creation_date stays NULL item = StockItem.objects.get(pk=self.pk_s6) self.assertIsNone(item.creation_date) + + +class TestRemoveMpttFieldsMigration(MigratorTestCase): + """Test data migration which removes MPTT fields (level, lft, rght, tree_id) from StockItem. + + The 'parent' field itself is untouched by this migration - only the MPTT + bookkeeping fields are removed. This test ensures that parent-child + relationships between StockItem objects survive the migration boundary. + """ + + migrate_from = ('stock', '0123_remove_stockitem_review_needed') + migrate_to = ('stock', '0125_remove_mptt_fields') + + def prepare(self): + """Create a tree of StockItem objects with parent-child relationships.""" + Part = self.old_state.apps.get_model('part', 'part') + StockItem = self.old_state.apps.get_model('stock', 'stockitem') + + part = Part.objects.create( + name='Migration Test Part', level=0, tree_id=0, lft=0, rght=0 + ) + + # Root stock item, with no parent + root = StockItem.objects.create( + part=part, quantity=100, level=0, tree_id=0, lft=0, rght=0 + ) + + # A set of child items, parented to the root item + children = [ + StockItem.objects.create( + part=part, quantity=10, parent=root, level=1, tree_id=0, lft=0, rght=0 + ) + for _ in range(3) + ] + + # A grandchild item, parented to the first child item + grandchild = StockItem.objects.create( + part=part, quantity=1, parent=children[0], level=2, tree_id=0, lft=0, rght=0 + ) + + # An unrelated top-level item, with no parent + orphan = StockItem.objects.create( + part=part, quantity=5, level=0, tree_id=0, lft=0, rght=0 + ) + + self.root_pk = root.pk + self.child_pks = [child.pk for child in children] + self.grandchild_pk = grandchild.pk + self.orphan_pk = orphan.pk + + self.assertEqual(StockItem.objects.count(), 6) + + def test_migration(self): + """Test that parent associations are preserved after MPTT fields are removed.""" + StockItem = self.new_state.apps.get_model('stock', 'stockitem') + + self.assertEqual(StockItem.objects.count(), 6) + + # The MPTT bookkeeping fields should no longer exist on the model + field_names = {field.name for field in StockItem._meta.get_fields()} + for removed_field in ['level', 'lft', 'rght', 'tree_id']: + self.assertNotIn(removed_field, field_names) + + # The root item still has no parent + root = StockItem.objects.get(pk=self.root_pk) + self.assertIsNone(root.parent_id) + + # Each child item is still correctly parented to the root item + for pk in self.child_pks: + child = StockItem.objects.get(pk=pk) + self.assertEqual(child.parent_id, self.root_pk) + + # The grandchild item is still correctly parented to the first child item + grandchild = StockItem.objects.get(pk=self.grandchild_pk) + self.assertEqual(grandchild.parent_id, self.child_pks[0]) + + # The orphan item still has no parent + orphan = StockItem.objects.get(pk=self.orphan_pk) + self.assertIsNone(orphan.parent_id) diff --git a/src/backend/InvenTree/stock/tests.py b/src/backend/InvenTree/stock/tests.py index b0c6e828af..255c3b9349 100644 --- a/src/backend/InvenTree/stock/tests.py +++ b/src/backend/InvenTree/stock/tests.py @@ -1138,7 +1138,6 @@ class VariantTest(StockTestBase): # Attempt to create the same serial number but for a variant (should fail!) # Reset the primary key and tree_id values item.pk = None - item.tree_id = None item.part = Part.objects.get(pk=10004) with self.assertRaises(ValidationError): @@ -1353,187 +1352,6 @@ class StockLocationTreeTest(StockTestBase): self.assertEqual(C22.get_ancestors().count(), 1) -class StockTreeTest(StockTestBase): - """Unit test for StockItem tree structure.""" - - def test_stock_split(self): - """Test that stock splitting works correctly.""" - part = Part.objects.create(name='My part', description='My part description') - location = StockLocation.objects.create(name='Test Location') - - # Create an initial stock item - item = StockItem.objects.create(part=part, quantity=1000, location=location) - - # Test that the initial MPTT values are correct - self.assertEqual(item.level, 0) - self.assertEqual(item.lft, 1) - self.assertEqual(item.rght, 2) - - children = [] - - self.assertEqual(item.get_descendants(include_self=False).count(), 0) - self.assertEqual(item.get_descendants(include_self=True).count(), 1) - - # Create child items by splitting stock - for idx in range(10): - child = item.splitStock(50, None, None) - children.append(child) - - # Check that the child item has been correctly created - self.assertEqual(child.parent.pk, item.pk) - self.assertEqual(child.tree_id, item.tree_id) - self.assertEqual(child.level, 1) - - item.refresh_from_db() - self.assertEqual(item.get_children().count(), idx + 1) - self.assertEqual(item.get_descendants(include_self=True).count(), idx + 2) - - item.refresh_from_db() - n = item.get_descendants(include_self=True).count() - - for child in children: - # Create multiple sub-childs - for _idx in range(3): - sub_child = child.splitStock(10, None, None) - self.assertEqual(sub_child.parent.pk, child.pk) - self.assertEqual(sub_child.tree_id, child.tree_id) - self.assertEqual(sub_child.level, 2) - - self.assertEqual(sub_child.get_ancestors(include_self=True).count(), 3) - - child.refresh_from_db() - self.assertEqual(child.get_descendants(include_self=True).count(), 4) - - item.refresh_from_db() - self.assertEqual(item.get_descendants(include_self=True).count(), n + 30) - - def test_tree_rebuild(self): - """Test that tree rebuild works correctly.""" - part = Part.objects.create(name='My part', description='My part description') - location = StockLocation.objects.create(name='Test Location') - - N = StockItem.objects.count() - - # Create an initial stock item - item = StockItem.objects.create(part=part, quantity=1000, location=location) - - # Split out ten child items - for _idx in range(10): - item.splitStock(10) - - item.refresh_from_db() - - self.assertEqual(StockItem.objects.count(), N + 11) - self.assertEqual(item.get_children().count(), 10) - self.assertEqual(item.get_descendants(include_self=True).count(), 11) - - # Split the first child item - child = item.get_children().first() - - self.assertEqual(child.parent, item) - self.assertEqual(child.tree_id, item.tree_id) - self.assertEqual(child.level, 1) - - # Split out three grandchildren - for _ in range(3): - child.splitStock(2) - - item.refresh_from_db() - child.refresh_from_db() - - self.assertEqual(child.get_descendants(include_self=True).count(), 4) - self.assertEqual(child.get_children().count(), 3) - - # Check tree structure for grandchildren - grandchildren = child.get_children() - - for gc in grandchildren: - self.assertEqual(gc.parent, child) - self.assertEqual(gc.parent.parent, item) - self.assertEqual(gc.tree_id, item.tree_id) - self.assertEqual(gc.level, 2) - self.assertGreater(gc.lft, child.lft) - self.assertLess(gc.rght, child.rght) - - self.assertEqual(item.get_children().count(), 10) - self.assertEqual(item.get_descendants(include_self=True).count(), 14) - - # Now, delete the child node - # We expect that the grandchildren will be re-parented to the parent node - child.delete() - - for gc in grandchildren: - gc.refresh_from_db() - - # Check that the grandchildren have been re-parented to the top-level - self.assertEqual(gc.parent, item) - self.assertEqual(gc.tree_id, item.tree_id) - self.assertEqual(gc.level, 1) - self.assertGreater(gc.lft, item.lft) - self.assertLess(gc.rght, item.rght) - - item.refresh_from_db() - - self.assertEqual(item.get_children().count(), 12) - self.assertEqual(item.get_descendants(include_self=True).count(), 13) - - def test_serialize(self): - """Test that StockItem serialization maintains tree structure.""" - part = Part.objects.create( - name='My part', description='My part description', trackable=True - ) - - N = StockItem.objects.count() - - # Create an initial stock item - item_1 = StockItem.objects.create(part=part, quantity=1000) - item_2 = item_1.splitStock(750) - - item_1.refresh_from_db() - - self.assertEqual(StockItem.objects.count(), N + 2) - self.assertEqual(item_1.get_children().count(), 1) - self.assertEqual(item_2.parent, item_1) - - loc = StockLocation.objects.filter(structural=False).first() - - # Serialize the secondary item - serials = [str(i) for i in range(20)] - items = item_2.serializeStock(20, serials, location=loc) - - self.assertEqual(len(items), 20) - self.assertEqual(StockItem.objects.count(), N + 22) - - item_1.refresh_from_db() - item_2.refresh_from_db() - - self.assertEqual(item_1.get_children().count(), 1) - self.assertEqual(item_2.get_children().count(), 20) - - for child in items: - self.assertEqual(child.tree_id, item_2.tree_id) - self.assertEqual(child.level, 2) - self.assertEqual(child.parent, item_2) - self.assertGreater(child.lft, item_2.lft) - self.assertLess(child.rght, item_2.rght) - self.assertEqual(child.location, loc) - self.assertIsNotNone(child.location) - self.assertEqual(child.tracking_info.count(), 2) - - # Delete item_2 : we expect that all children will be re-parented to item_1 - item_2.delete() - - for child in items: - child.refresh_from_db() - - # Check that the children have been re-parented to item_1 - self.assertEqual(child.parent, item_1) - self.assertEqual(child.tree_id, item_1.tree_id) - self.assertEqual(child.level, 1) - self.assertGreater(child.lft, item_1.lft) - self.assertLess(child.rght, item_1.rght) - - class TestResultTest(StockTestBase): """Tests for the StockItemTestResult model.""" diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index f8a5f05007..2e93f01ba4 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -299,7 +299,7 @@ export default function StockDetail() { content: stockitem?.pk ? ( ) : (