mirror of
https://github.com/inventree/InvenTree.git
synced 2026-07-17 20:23:50 +00:00
[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
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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})
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -299,7 +299,7 @@ export default function StockDetail() {
|
||||
content: stockitem?.pk ? (
|
||||
<StockItemTable
|
||||
tableName='child-stock'
|
||||
params={{ ancestor: stockitem.pk }}
|
||||
params={{ parent: stockitem.pk }}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton />
|
||||
|
||||
Reference in New Issue
Block a user