diff --git a/src/backend/InvenTree/InvenTree/helpers.py b/src/backend/InvenTree/InvenTree/helpers.py
index d7a031c7af..0ba509aaef 100644
--- a/src/backend/InvenTree/InvenTree/helpers.py
+++ b/src/backend/InvenTree/InvenTree/helpers.py
@@ -487,6 +487,11 @@ def extract_serial_numbers(input_string, expected_quantity: int, starting_value=
except ValueError:
raise ValidationError([_('Invalid quantity provided')])
+ if expected_quantity > 1000:
+ raise ValidationError({
+ 'quantity': [_('Cannot serialize more than 1000 items at once')]
+ })
+
input_string = str(input_string).strip() if input_string else ''
if len(input_string) == 0:
diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py
index 0eabc9615c..984a2027b5 100644
--- a/src/backend/InvenTree/InvenTree/settings.py
+++ b/src/backend/InvenTree/InvenTree/settings.py
@@ -1101,7 +1101,7 @@ COOKIE_MODE = (
# Valid modes (as per the django settings documentation)
valid_cookie_modes = ['lax', 'strict', 'none']
-if not DEBUG and COOKIE_MODE in valid_cookie_modes:
+if not DEBUG and not TESTING and COOKIE_MODE in valid_cookie_modes:
# Set the cookie mode (in production mode only)
COOKIE_MODE = COOKIE_MODE.capitalize()
else:
diff --git a/src/backend/InvenTree/InvenTree/unit_test.py b/src/backend/InvenTree/InvenTree/unit_test.py
index 2128f6b768..a5430762b2 100644
--- a/src/backend/InvenTree/InvenTree/unit_test.py
+++ b/src/backend/InvenTree/InvenTree/unit_test.py
@@ -263,7 +263,9 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
MAX_QUERY_TIME = 7.5
@contextmanager
- def assertNumQueriesLessThan(self, value, using='default', verbose=False, url=None):
+ def assertNumQueriesLessThan(
+ self, value, using='default', verbose=False, url=None, log_to_file=False
+ ):
"""Context manager to check that the number of queries is less than a certain value.
Example:
@@ -281,6 +283,12 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
f'Query count exceeded at {url}: Expected < {value} queries, got {n}'
) # pragma: no cover
+ # Useful for debugging, disabled by default
+ if log_to_file:
+ with open('queries.txt', 'w', encoding='utf-8') as f:
+ for q in context.captured_queries:
+ f.write(str(q['sql']) + '\n')
+
if verbose and n >= value:
msg = f'\r\n{json.dumps(context.captured_queries, indent=4)}' # pragma: no cover
else:
diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py
index 22b98d031d..2209c0ae27 100644
--- a/src/backend/InvenTree/build/models.py
+++ b/src/backend/InvenTree/build/models.py
@@ -834,6 +834,16 @@ class Build(
location: Override location
auto_allocate: Automatically allocate stock with matching serial numbers
"""
+
+ trackable_parts = self.part.get_trackable_parts()
+
+ # Create (and cache) a map of valid parts for allocation
+ valid_parts = {}
+
+ for bom_item in trackable_parts:
+ parts = bom_item.get_valid_parts_for_allocation()
+ valid_parts[bom_item.pk] = list([part.pk for part in parts])
+
user = kwargs.get('user', None)
batch = kwargs.get('batch', self.batch)
location = kwargs.get('location', None)
@@ -843,81 +853,51 @@ class Build(
if location is None:
location = self.destination or self.part.get_default_location()
- """
- Determine if we can create a single output (with quantity > 0),
- or multiple outputs (with quantity = 1)
- """
-
- def _add_tracking_entry(output, user):
- """Helper function to add a tracking entry to the newly created output"""
- deltas = {
- 'quantity': float(output.quantity),
- 'buildorder': self.pk,
- }
-
- if output.batch:
- deltas['batch'] = output.batch
-
- if output.serial:
- deltas['serial'] = output.serial
-
- if output.location:
- deltas['location'] = output.location.pk
-
- output.add_tracking_entry(StockHistoryCode.BUILD_OUTPUT_CREATED, user, deltas)
-
- multiple = False
-
- # Serial numbers are provided? We need to split!
- if serials:
- multiple = True
-
- # BOM has trackable parts, so we must split!
- if self.part.has_trackable_parts:
- multiple = True
-
- if multiple:
+ # We are generating multiple serialized outputs
+ if serials or self.part.has_trackable_parts:
"""Create multiple build outputs with a single quantity of 1."""
- # Quantity *must* be an integer at this point!
- quantity = int(quantity)
+ # Create tracking entries for each item
+ tracking = []
+ allocations = []
- for ii in range(quantity):
+ outputs = stock.models.StockItem._create_serial_numbers(
+ serials,
+ part=self.part,
+ build=self,
+ batch=batch,
+ is_building=True
+ )
- if serials:
- serial = serials[ii]
- else:
- serial = None
+ for output in outputs:
+ # Generate a new historical tracking entry
+ if entry := output.add_tracking_entry(
+ StockHistoryCode.BUILD_OUTPUT_CREATED,
+ user,
+ deltas={
+ 'quantity': 1,
+ 'buildorder': self.pk,
+ 'batch': output.batch,
+ 'serial': output.serial,
+ 'location': location.pk if location else None
+ },
+ commit=False
+ ):
+ tracking.append(entry)
- output = stock.models.StockItem.objects.create(
- quantity=1,
- location=location,
- part=self.part,
- build=self,
- batch=batch,
- serial=serial,
- is_building=True,
- )
+ # Auto-allocate stock based on serial number
+ if auto_allocate:
+ allocations = []
- _add_tracking_entry(output, user)
-
- if auto_allocate and serial is not None:
-
- # Get a list of BomItem objects which point to "trackable" parts
-
- for bom_item in self.part.get_trackable_parts():
-
- parts = bom_item.get_valid_parts_for_allocation()
+ for bom_item in trackable_parts:
+ valid_part_ids = valid_parts.get(bom_item.pk, [])
items = stock.models.StockItem.objects.filter(
- part__in=parts,
- serial=str(serial),
+ part__pk__in=valid_part_ids,
+ serial=output.serial,
quantity=1,
).filter(stock.models.StockItem.IN_STOCK_FILTER)
- """
- Test if there is a matching serial number!
- """
if items.exists() and items.count() == 1:
stock_item = items[0]
@@ -929,15 +909,23 @@ class Build(
)
# Allocate the stock items against the BuildLine
- BuildItem.objects.create(
- build_line=build_line,
- stock_item=stock_item,
- quantity=1,
- install_into=output,
+ allocations.append(
+ BuildItem(
+ build_line=build_line,
+ stock_item=stock_item,
+ quantity=1,
+ install_into=output,
+ )
)
except BuildLine.DoesNotExist:
pass
+ # Bulk create tracking entries
+ stock.models.StockItemTracking.objects.bulk_create(tracking)
+
+ # Generate stock allocations
+ BuildItem.objects.bulk_create(allocations)
+
else:
"""Create a single build output of the given quantity."""
@@ -950,7 +938,16 @@ class Build(
is_building=True
)
- _add_tracking_entry(output, user)
+ output.add_tracking_entry(
+ StockHistoryCode.BUILD_OUTPUT_CREATED,
+ user,
+ deltas={
+ 'quantity': quantity,
+ 'buildorder': self.pk,
+ 'batch': batch,
+ 'location': location.pk if location else None
+ }
+ )
if self.status == BuildStatus.PENDING:
self.status = BuildStatus.PRODUCTION.value
diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py
index 186d4eabcb..2ce969537d 100644
--- a/src/backend/InvenTree/build/serializers.py
+++ b/src/backend/InvenTree/build/serializers.py
@@ -404,11 +404,7 @@ class BuildOutputCreateSerializer(serializers.Serializer):
})
# Check for conflicting serial numbesr
- existing = []
-
- for serial in self.serials:
- if not part.validate_serial_number(serial):
- existing.append(serial)
+ existing = part.find_conflicting_serial_numbers(self.serials)
if len(existing) > 0:
diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py
index bfc499d611..5088093468 100644
--- a/src/backend/InvenTree/part/models.py
+++ b/src/backend/InvenTree/part/models.py
@@ -833,12 +833,38 @@ class Part(
# This serial number is perfectly valid
return True
- def find_conflicting_serial_numbers(self, serials: list):
+ def find_conflicting_serial_numbers(self, serials: list) -> list:
"""For a provided list of serials, return a list of those which are conflicting."""
+ from part.models import Part
+ from stock.models import StockItem
+
conflicts = []
+ # First, check for raw conflicts based on efficient database queries
+ if get_global_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False):
+ # Serial number must be unique across *all* parts
+ parts = Part.objects.all()
+ else:
+ # Serial number must only be unique across this part "tree"
+ parts = Part.objects.filter(tree_id=self.tree_id)
+
+ items = StockItem.objects.filter(part__in=parts, serial__in=serials)
+ items = items.order_by('serial_int', 'serial')
+
+ for item in items:
+ conflicts.append(item.serial)
+
for serial in serials:
- if not self.validate_serial_number(serial, part=self):
+ if serial in conflicts:
+ # Already found a conflict, no need to check further
+ continue
+
+ try:
+ self.validate_serial_number(
+ serial, raise_error=True, check_duplicates=False
+ )
+ except ValidationError:
+ # Serial number is invalid (as determined by plugin)
conflicts.append(serial)
return conflicts
diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py
index d6fff9e6c4..ed51cdc537 100644
--- a/src/backend/InvenTree/stock/api.py
+++ b/src/backend/InvenTree/stock/api.py
@@ -922,12 +922,12 @@ class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
data.update(self.clean_data(request.data))
quantity = data.get('quantity', None)
+ location = data.get('location', None)
if quantity is None:
raise ValidationError({'quantity': _('Quantity is required')})
try:
- Part.objects.prefetch_related(None)
part = Part.objects.get(pk=data.get('part', None))
except (ValueError, Part.DoesNotExist):
raise ValidationError({'part': _('Valid part must be supplied')})
@@ -951,15 +951,13 @@ class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
serials = None
# Check if a set of serial numbers was provided
- serial_numbers = data.get('serial_numbers', '')
+ serial_numbers = data.pop('serial_numbers', '')
# Check if the supplier_part has a package size defined, which is not 1
- if 'supplier_part' in data and data['supplier_part'] is not None:
+ if supplier_part_id := data.get('supplier_part', None):
try:
- supplier_part = SupplierPart.objects.get(
- pk=data.get('supplier_part', None)
- )
- except (ValueError, SupplierPart.DoesNotExist):
+ supplier_part = SupplierPart.objects.get(pk=supplier_part_id)
+ except Exception:
raise ValidationError({
'supplier_part': _('The given supplier part does not exist')
})
@@ -988,8 +986,7 @@ class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
# Now remove the flag from data, so that it doesn't interfere with saving
# Do this regardless of results above
- if 'use_pack_size' in data:
- data.pop('use_pack_size')
+ data.pop('use_pack_size', None)
# Assign serial numbers for a trackable part
if serial_numbers:
@@ -1011,22 +1008,20 @@ class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
invalid = []
errors = []
- for serial in serials:
- try:
- part.validate_serial_number(serial, raise_error=True)
- except DjangoValidationError as exc:
- # Catch raised error to extract specific error information
- invalid.append(serial)
+ try:
+ invalid = part.find_conflicting_serial_numbers(serials)
+ except DjangoValidationError as exc:
+ errors.append(exc.message)
- if exc.message not in errors:
- errors.append(exc.message)
-
- if len(errors) > 0:
+ if len(invalid) > 0:
msg = _('The following serial numbers already exist or are invalid')
msg += ' : '
msg += ','.join([str(e) for e in invalid])
- raise ValidationError({'serial_numbers': [*errors, msg]})
+ errors.append(msg)
+
+ if len(errors) > 0:
+ raise ValidationError({'serial_numbers': errors})
except DjangoValidationError as e:
raise ValidationError({
@@ -1043,34 +1038,43 @@ class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
serializer.is_valid(raise_exception=True)
with transaction.atomic():
- # Create an initial StockItem object
- item = serializer.save()
-
if serials:
- # Assign the first serial number to the "master" item
- item.serial = serials[0]
+ # Create multiple serialized StockItem objects
+ items = StockItem._create_serial_numbers(
+ serials, **serializer.validated_data
+ )
- # Save the item (with user information)
- item.save(user=user)
+ # Next, bulk-create stock tracking entries for the newly created items
+ tracking = []
- if serials:
- for serial in serials[1:]:
- # Create a duplicate stock item with the next serial number
- item.pk = None
- item.serial = serial
+ for item in items:
+ if entry := item.add_tracking_entry(
+ StockHistoryCode.CREATED,
+ user,
+ deltas={'status': item.status},
+ location=location,
+ quantity=float(item.quantity),
+ commit=False,
+ ):
+ tracking.append(entry)
- item.save(user=user)
+ StockItemTracking.objects.bulk_create(tracking)
response_data = {'quantity': quantity, 'serial_numbers': serials}
else:
+ # Create a single StockItem object
+ # Note: This automatically creates a tracking entry
+ item = serializer.save()
+ item.save(user=user)
+
response_data = serializer.data
- return Response(
- response_data,
- status=status.HTTP_201_CREATED,
- headers=self.get_success_headers(serializer.data),
- )
+ return Response(
+ response_data,
+ status=status.HTTP_201_CREATED,
+ headers=self.get_success_headers(serializer.data),
+ )
def get_queryset(self, *args, **kwargs):
"""Annotate queryset before returning."""
diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py
index 46c8d30595..d5ea650c4b 100644
--- a/src/backend/InvenTree/stock/models.py
+++ b/src/backend/InvenTree/stock/models.py
@@ -12,7 +12,7 @@ from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models, transaction
-from django.db.models import Q, Sum
+from django.db.models import Q, QuerySet, Sum
from django.db.models.functions import Coalesce
from django.db.models.signals import post_delete, post_save, pre_delete
from django.db.utils import IntegrityError, OperationalError
@@ -33,6 +33,7 @@ import InvenTree.ready
import InvenTree.tasks
import report.mixins
import report.models
+import stock.tasks
from build import models as BuildModels
from common.icons import validate_icon
from common.settings import get_global_setting
@@ -459,6 +460,97 @@ class StockItem(
& Q(expiry_date__lt=InvenTree.helpers.current_date())
)
+ @classmethod
+ def _create_serial_numbers(cls, serials: list, **kwargs) -> QuerySet:
+ """Create multiple stock items with the provided serial numbers.
+
+ Arguments:
+ serials: List of serial numbers to create
+ **kwargs: Additional keyword arguments to pass to the StockItem creation function
+
+ Returns:
+ QuerySet: The created StockItem objects
+
+ raises:
+ ValidationError: If any of the provided serial numbers are invalid
+
+ This method uses bulk_create to create multiple StockItem objects in a single query,
+ which is much more efficient than creating them one-by-one.
+
+ However, it does not perform any validation checks on the provided serial numbers,
+ and also does not generate any "stock tracking entries".
+
+ Note: This is an 'internal' function and should not be used by external code / plugins.
+ """
+ # Ensure the primary-key field is not provided
+ kwargs.pop('id', None)
+ kwargs.pop('pk', None)
+
+ part = kwargs.get('part')
+
+ if not part:
+ raise ValidationError({'part': _('Part must be specified')})
+
+ # Create a list of StockItem objects
+ items = []
+
+ # Provide some default field values
+ data = {**kwargs}
+
+ # Remove some extraneous keys which cause issues
+ for key in ['parent_id', 'part_id', 'build_id']:
+ data.pop(key, None)
+
+ data['parent'] = kwargs.pop('parent', None)
+ data['tree_id'] = kwargs.pop('tree_id', 0)
+ data['level'] = kwargs.pop('level', 0)
+ data['rght'] = kwargs.pop('rght', 0)
+ data['lft'] = kwargs.pop('lft', 0)
+
+ # Force single quantity for each item
+ data['quantity'] = 1
+
+ for serial in serials:
+ data['serial'] = serial
+ data['serial_int'] = StockItem.convert_serial_to_int(serial)
+
+ items.append(StockItem(**data))
+
+ # Create the StockItem objects in bulk
+ StockItem.objects.bulk_create(items)
+
+ # Return the newly created StockItem objects
+ return StockItem.objects.filter(part=part, serial__in=serials)
+
+ @staticmethod
+ def convert_serial_to_int(serial: str) -> int:
+ """Convert the provided serial number to an integer value.
+
+ This function hooks into the plugin system to allow for custom serial number conversion.
+ """
+ from plugin.registry import registry
+
+ # First, let any plugins convert this serial number to an integer value
+ # If a non-null value is returned (by any plugin) we will use that
+
+ for plugin in registry.with_mixin('validation'):
+ serial_int = plugin.convert_serial_to_int(serial)
+
+ # Save the first returned result
+ if serial_int is not None:
+ # Ensure that it is clipped within a range allowed in the database schema
+ clip = 0x7FFFFFFF
+ serial_int = abs(serial_int)
+ serial_int = min(serial_int, clip)
+ # Return the first non-null value
+ return serial_int
+
+ # None of the plugins provided a valid integer value
+ if serial not in [None, '']:
+ return InvenTree.helpers.extract_int(serial)
+ else:
+ return None
+
def update_serial_number(self):
"""Update the 'serial_int' field, to be an integer representation of the serial number.
@@ -466,35 +558,15 @@ class StockItem(
"""
serial = str(getattr(self, 'serial', '')).strip()
- from plugin.registry import registry
+ serial_int = self.convert_serial_to_int(serial)
- # First, let any plugins convert this serial number to an integer value
- # If a non-null value is returned (by any plugin) we will use that
+ try:
+ serial_int = int(serial_int)
- serial_int = None
-
- for plugin in registry.with_mixin('validation'):
- serial_int = plugin.convert_serial_to_int(serial)
-
- if serial_int is not None:
- # Save the first returned result
- # Ensure that it is clipped within a range allowed in the database schema
- clip = 0x7FFFFFFF
-
- serial_int = abs(serial_int)
-
- serial_int = min(serial_int, clip)
-
- self.serial_int = serial_int
- return
-
- # If we get to this point, none of the available plugins provided an integer value
-
- # Default value if we cannot convert to an integer
- serial_int = 0
-
- if serial not in [None, '']:
- serial_int = InvenTree.helpers.extract_int(serial)
+ if serial_int <= 0:
+ serial_int = 0
+ except (ValueError, TypeError):
+ serial_int = 0
self.serial_int = serial_int
@@ -1452,6 +1524,7 @@ class StockItem(
user: User,
deltas: dict | None = None,
notes: str = '',
+ commit: bool = True,
**kwargs,
):
"""Add a history tracking entry for this StockItem.
@@ -1461,6 +1534,9 @@ class StockItem(
user (User): The user performing this action
deltas (dict, optional): A map of the changes made to the model. Defaults to None.
notes (str, optional): URL associated with this tracking entry. Defaults to ''.
+
+ Returns:
+ StockItemTracking: The created tracking entry
"""
if deltas is None:
deltas = {}
@@ -1471,7 +1547,7 @@ class StockItem(
and len(deltas) == 0
and not notes
):
- return
+ return None
# Has a location been specified?
location = kwargs.get('location')
@@ -1485,7 +1561,7 @@ class StockItem(
if quantity:
deltas['quantity'] = float(quantity)
- entry = StockItemTracking.objects.create(
+ entry = StockItemTracking(
item=self,
tracking_type=entry_type.value,
user=user,
@@ -1494,7 +1570,10 @@ class StockItem(
deltas=deltas,
)
- entry.save()
+ if commit:
+ entry.save()
+
+ return entry
@transaction.atomic
def serializeStock(self, quantity, serials, user, notes='', location=None):
@@ -1536,7 +1615,7 @@ class StockItem(
if type(serials) not in [list, tuple]:
raise ValidationError({
- 'serial_numbers': _('Serial numbers must be a list of integers')
+ 'serial_numbers': _('Serial numbers must be provided as a list')
})
if quantity != len(serials):
@@ -1552,45 +1631,54 @@ class StockItem(
msg = _('Serial numbers already exist') + f': {exists}'
raise ValidationError({'serial_numbers': msg})
- # Create a new stock item for each unique serial number
- for serial in serials:
- # Create a copy of this StockItem
- new_item = StockItem.objects.get(pk=self.pk)
- new_item.quantity = 1
- new_item.serial = serial
- new_item.pk = None
- new_item.parent = self
+ # Serialize this StockItem
+ data = dict(StockItem.objects.filter(pk=self.pk).values()[0])
- if location:
- new_item.location = location
+ if location:
+ data['location'] = location
- # The item already has a transaction history, don't create a new note
- new_item.save(user=user, notes=notes)
+ data['part'] = self.part
+ data['parent'] = self
+ data['tree_id'] = self.tree_id
- # Copy entire transaction history
- new_item.copyHistoryFrom(self)
+ # Generate a new serial number for each item
+ items = StockItem._create_serial_numbers(serials, **data)
- # Copy test result history
- new_item.copyTestResultsFrom(self)
+ # Create a new tracking entry for each item
+ history_items = []
- # Create a new stock tracking item
- new_item.add_tracking_entry(
+ for item in items:
+ if entry := item.add_tracking_entry(
StockHistoryCode.ASSIGNED_SERIAL,
user,
notes=notes,
- deltas={'serial': serial},
+ deltas={'serial': item.serial},
location=location,
- )
+ commit=False,
+ ):
+ history_items.append(entry)
+
+ StockItemTracking.objects.bulk_create(history_items)
+
+ # Duplicate test results
+ test_results = []
+
+ for test_result in self.test_results.all():
+ for item in items:
+ test_result.pk = None
+ test_result.stock_item = item
+
+ test_results.append(test_result)
+
+ StockItemTestResult.objects.bulk_create(test_results)
# Remove the equivalent number of items
self.take_stock(quantity, user, notes=notes)
# Rebuild the stock tree
- try:
- StockItem.objects.partial_rebuild(tree_id=self.tree_id)
- except Exception:
- logger.warning('Failed to rebuild stock tree during serializeStock')
- StockItem.objects.rebuild()
+ InvenTree.tasks.offload_task(
+ stock.tasks.rebuild_stock_item_tree, tree_id=self.tree_id
+ )
@transaction.atomic
def copyHistoryFrom(self, other):
@@ -1822,12 +1910,10 @@ class StockItem(
self.save()
# Rebuild stock trees as required
- try:
- for tree_id in tree_ids:
- StockItem.objects.partial_rebuild(tree_id=tree_id)
- except Exception:
- logger.warning('Rebuilding entire StockItem tree during merge_stock_items')
- StockItem.objects.rebuild()
+ for tree_id in tree_ids:
+ InvenTree.tasks.offload_task(
+ stock.tasks.rebuild_stock_item_tree, tree_id=tree_id
+ )
@transaction.atomic
def splitStock(self, quantity, location=None, user=None, **kwargs):
@@ -1922,11 +2008,9 @@ class StockItem(
)
# Rebuild the tree for this parent item
- try:
- StockItem.objects.partial_rebuild(tree_id=self.tree_id)
- except Exception:
- logger.warning('Rebuilding entire StockItem tree')
- StockItem.objects.rebuild()
+ InvenTree.tasks.offload_task(
+ stock.tasks.rebuild_stock_item_tree, tree_id=self.tree_id
+ )
# Attempt to reload the new item from the database
try:
diff --git a/src/backend/InvenTree/stock/tasks.py b/src/backend/InvenTree/stock/tasks.py
new file mode 100644
index 0000000000..a3791649b7
--- /dev/null
+++ b/src/backend/InvenTree/stock/tasks.py
@@ -0,0 +1,24 @@
+"""Background tasks for the stock app."""
+
+import logging
+
+logger = logging.getLogger('inventree')
+
+
+def rebuild_stock_item_tree(tree_id=None):
+ """Rebuild the stock tree structure.
+
+ The StockItem tree uses the MPTT library to manage the tree structure.
+ """
+ from stock.models import StockItem
+
+ if tree_id:
+ try:
+ StockItem.objects.partial_rebuild(tree_id)
+ except Exception:
+ logger.warning('Failed to rebuild StockItem tree')
+ # If the partial rebuild fails, rebuild the entire tree
+ StockItem.objects.rebuild()
+ else:
+ # No tree_id provided, so rebuild the entire tree
+ StockItem.objects.rebuild()
diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py
index 92660abc2c..397a559b3b 100644
--- a/src/backend/InvenTree/stock/test_api.py
+++ b/src/backend/InvenTree/stock/test_api.py
@@ -2406,7 +2406,7 @@ class StockStatisticsTest(StockAPITestCase):
fixtures = [*StockAPITestCase.fixtures, 'build']
- def test_test_statics(self):
+ def test_test_statistics(self):
"""Test the test statistics API endpoints."""
part = Part.objects.first()
response = self.get(
diff --git a/src/frontend/src/components/buttons/SpotlightButton.tsx b/src/frontend/src/components/buttons/SpotlightButton.tsx
index 8631178c31..7ca95d78bf 100644
--- a/src/frontend/src/components/buttons/SpotlightButton.tsx
+++ b/src/frontend/src/components/buttons/SpotlightButton.tsx
@@ -13,6 +13,7 @@ export function SpotlightButton() {
onClick={() => firstSpotlight.open()}
title={t`Open spotlight`}
variant="transparent"
+ aria-label="open-spotlight"
>
diff --git a/src/frontend/src/components/nav/Header.tsx b/src/frontend/src/components/nav/Header.tsx
index 7ba9ef6f8b..a2b7e870b2 100644
--- a/src/frontend/src/components/nav/Header.tsx
+++ b/src/frontend/src/components/nav/Header.tsx
@@ -103,7 +103,11 @@ export function Header() {
-
+
@@ -119,6 +123,7 @@ export function Header() {
diff --git a/src/frontend/src/components/nav/SearchDrawer.tsx b/src/frontend/src/components/nav/SearchDrawer.tsx
index e4fd5b1fff..4f69d37553 100644
--- a/src/frontend/src/components/nav/SearchDrawer.tsx
+++ b/src/frontend/src/components/nav/SearchDrawer.tsx
@@ -68,7 +68,13 @@ function QueryResultGroup({
const model = getModelInfo(query.model);
return (
-
+
@@ -84,13 +90,14 @@ function QueryResultGroup({
color="red"
variant="transparent"
radius="xs"
+ aria-label={`remove-search-group-${query.model}`}
onClick={() => onRemove(query.model)}
>
-
+
{query.results.results.map((result: any) => (
@@ -367,6 +374,7 @@ export function SearchDrawer({
title={
-
-
-
+
+
+
{allPanels.map(
(panel) =>
!panel.hidden && (
diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx
index 12b023ecaf..4f4ef1dca7 100644
--- a/src/frontend/src/forms/StockForms.tsx
+++ b/src/frontend/src/forms/StockForms.tsx
@@ -50,8 +50,10 @@ import { useGlobalSettingsState } from '../states/SettingsState';
export function useStockFields({
item_detail,
part_detail,
+ partId,
create = false
}: {
+ partId?: number;
item_detail?: any;
part_detail?: any;
create: boolean;
@@ -81,7 +83,7 @@ export function useStockFields({
return useMemo(() => {
const fields: ApiFormFieldSet = {
part: {
- value: part,
+ value: partId,
disabled: !create,
filters: {
active: create ? true : undefined
@@ -201,7 +203,8 @@ export function useStockFields({
batchCode,
serialNumbers,
trackable,
- create
+ create,
+ partId
]);
}
diff --git a/src/frontend/src/tables/stock/StockItemTable.tsx b/src/frontend/src/tables/stock/StockItemTable.tsx
index 8bfbe3f078..22c18694bd 100644
--- a/src/frontend/src/tables/stock/StockItemTable.tsx
+++ b/src/frontend/src/tables/stock/StockItemTable.tsx
@@ -401,7 +401,7 @@ export function StockItemTable({
};
}, [table]);
- const stockItemFields = useStockFields({ create: true });
+ const stockItemFields = useStockFields({ create: true, partId: params.part });
const newStockItem = useCreateApiFormModal({
url: ApiEndpoints.stock_item_list,
diff --git a/src/frontend/tests/modals.spec.ts b/src/frontend/tests/modals.spec.ts
index 406accc94d..5499512b3d 100644
--- a/src/frontend/tests/modals.spec.ts
+++ b/src/frontend/tests/modals.spec.ts
@@ -5,7 +5,7 @@ test('Modals as admin', async ({ page }) => {
await doQuickLogin(page, 'admin', 'inventree');
// use server info
- await page.getByRole('button', { name: 'Open spotlight' }).click();
+ await page.getByLabel('open-spotlight').click();
await page
.getByRole('button', {
name: 'Server Information About this Inventree instance'
@@ -17,7 +17,7 @@ test('Modals as admin', async ({ page }) => {
await page.waitForURL('**/platform/home');
// use license info
- await page.getByRole('button', { name: 'Open spotlight' }).click();
+ await page.getByLabel('open-spotlight').click();
await page
.getByRole('button', {
name: 'License Information Licenses for dependencies of the service'
@@ -44,7 +44,7 @@ test('Modals as admin', async ({ page }) => {
.click();
// use about
- await page.getByRole('button', { name: 'Open spotlight' }).click();
+ await page.getByLabel('open-spotlight').click();
await page
.getByRole('button', { name: 'About InvenTree About the InvenTree org' })
.click();
diff --git a/src/frontend/tests/pages/pui_orders.spec.ts b/src/frontend/tests/pages/pui_orders.spec.ts
index f4e80b5103..167b9b7751 100644
--- a/src/frontend/tests/pages/pui_orders.spec.ts
+++ b/src/frontend/tests/pages/pui_orders.spec.ts
@@ -178,3 +178,53 @@ test('Purchase Orders - Barcodes', async ({ page }) => {
await page.waitForTimeout(500);
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
});
+
+test('Purchase Orders - General', async ({ page }) => {
+ await doQuickLogin(page);
+
+ await page.getByRole('tab', { name: 'Purchasing' }).click();
+ await page.getByRole('cell', { name: 'PO0012' }).click();
+ await page.waitForTimeout(200);
+
+ await page.getByRole('tab', { name: 'Line Items' }).click();
+ await page.getByRole('tab', { name: 'Received Stock' }).click();
+ await page.getByRole('tab', { name: 'Attachments' }).click();
+ await page.getByRole('tab', { name: 'Purchasing' }).click();
+ await page.getByRole('tab', { name: 'Suppliers' }).click();
+ await page.getByText('Arrow', { exact: true }).click();
+ await page.waitForTimeout(200);
+
+ await page.getByRole('tab', { name: 'Supplied Parts' }).click();
+ await page.getByRole('tab', { name: 'Purchase Orders' }).click();
+ await page.getByRole('tab', { name: 'Stock Items' }).click();
+ await page.getByRole('tab', { name: 'Contacts' }).click();
+ await page.getByRole('tab', { name: 'Addresses' }).click();
+ await page.getByRole('tab', { name: 'Attachments' }).click();
+ await page.getByRole('tab', { name: 'Purchasing' }).click();
+ await page.getByRole('tab', { name: 'Manufacturers' }).click();
+ await page.getByText('AVX Corporation').click();
+ await page.waitForTimeout(200);
+
+ await page.getByRole('tab', { name: 'Addresses' }).click();
+ await page.getByRole('cell', { name: 'West Branch' }).click();
+ await page.locator('.mantine-ScrollArea-root').click();
+ await page
+ .getByRole('row', { name: 'West Branch Yes Surf Avenue 9' })
+ .getByRole('button')
+ .click();
+ await page.getByRole('menuitem', { name: 'Edit' }).click();
+
+ await page.getByLabel('text-field-title').waitFor();
+ await page.getByLabel('text-field-line2').waitFor();
+
+ // Read the current value of the cell, to ensure we always *change* it!
+ const value = await page.getByLabel('text-field-line2').inputValue();
+ await page
+ .getByLabel('text-field-line2')
+ .fill(value == 'old' ? 'new' : 'old');
+
+ await page.getByRole('button', { name: 'Submit' }).isEnabled();
+
+ await page.getByRole('button', { name: 'Submit' }).click();
+ await page.getByRole('tab', { name: 'Details' }).waitFor();
+});
diff --git a/src/frontend/tests/pages/pui_stock.spec.ts b/src/frontend/tests/pages/pui_stock.spec.ts
new file mode 100644
index 0000000000..1a7d2d4f06
--- /dev/null
+++ b/src/frontend/tests/pages/pui_stock.spec.ts
@@ -0,0 +1,104 @@
+import { test } from '../baseFixtures.js';
+import { baseUrl } from '../defaults.js';
+import { doQuickLogin } from '../login.js';
+
+test('Stock', async ({ page }) => {
+ await doQuickLogin(page);
+
+ await page.goto(`${baseUrl}/stock/location/index/`);
+ await page.waitForURL('**/platform/stock/location/**');
+
+ await page.getByRole('tab', { name: 'Location Details' }).click();
+ await page.waitForURL('**/platform/stock/location/index/details');
+
+ await page.getByRole('tab', { name: 'Stock Items' }).click();
+ await page.getByText('1551ABK').first().click();
+
+ await page.getByRole('tab', { name: 'Stock', exact: true }).click();
+ await page.waitForURL('**/platform/stock/**');
+ await page.getByRole('tab', { name: 'Stock Locations' }).click();
+ await page.getByRole('cell', { name: 'Electronics Lab' }).first().click();
+ await page.getByRole('tab', { name: 'Default Parts' }).click();
+ await page.getByRole('tab', { name: 'Stock Locations' }).click();
+ await page.getByRole('tab', { name: 'Stock Items' }).click();
+ await page.getByRole('tab', { name: 'Location Details' }).click();
+
+ await page.goto(`${baseUrl}/stock/item/1194/details`);
+ await page.getByText('D.123 | Doohickey').waitFor();
+ await page.getByText('Batch Code: BX-123-2024-2-7').waitFor();
+ await page.getByRole('tab', { name: 'Stock Tracking' }).click();
+ await page.getByRole('tab', { name: 'Test Data' }).click();
+ await page.getByText('395c6d5586e5fb656901d047be27e1f7').waitFor();
+ await page.getByRole('tab', { name: 'Installed Items' }).click();
+});
+
+test('Stock - Location Tree', async ({ page }) => {
+ await doQuickLogin(page);
+
+ await page.goto(`${baseUrl}/stock/location/index/`);
+ await page.waitForURL('**/platform/stock/location/**');
+ await page.getByRole('tab', { name: 'Location Details' }).click();
+
+ await page.getByLabel('nav-breadcrumb-action').click();
+ await page.getByLabel('nav-tree-toggle-1}').click();
+ await page.getByLabel('nav-tree-item-2').click();
+
+ await page.getByLabel('breadcrumb-2-storage-room-a').waitFor();
+ await page.getByLabel('breadcrumb-1-factory').click();
+
+ await page.getByRole('cell', { name: 'Factory' }).first().waitFor();
+});
+
+test('Stock - Serial Numbers', async ({ page }) => {
+ await doQuickLogin(page);
+
+ // Use the "global search" functionality to find a part we are interested in
+ // This is to exercise the search functionality and ensure it is working as expected
+ await page.getByLabel('open-search').click();
+
+ await page.getByLabel('global-search-input').clear();
+ await page.getByLabel('global-search-input').fill('widget green');
+
+ // Remove the "stock item" results group
+ await page.getByLabel('remove-search-group-stockitem').click();
+
+ await page
+ .getByText(/widget\.green/)
+ .first()
+ .click();
+
+ await page
+ .getByLabel('panel-tabs-part')
+ .getByRole('tab', { name: 'Stock', exact: true })
+ .click();
+ await page.getByLabel('action-button-add-stock-item').click();
+
+ // Initially fill with invalid serial/quantity combinations
+ await page.getByLabel('text-field-serial_numbers').fill('200-250');
+ await page.getByLabel('number-field-quantity').fill('10');
+
+ await page.getByRole('button', { name: 'Submit' }).click();
+
+ // Expected error messages
+ await page.getByText('Errors exist for one or more form fields').waitFor();
+ await page
+ .getByText(/exceeds allowed quantity/)
+ .first()
+ .waitFor();
+
+ // Now, with correct quantity
+ await page.getByLabel('number-field-quantity').fill('51');
+ await page.getByRole('button', { name: 'Submit' }).click();
+ await page
+ .getByText(
+ /The following serial numbers already exist or are invalid : 200,201,202,203,204/
+ )
+ .first()
+ .waitFor();
+
+ // Expected error messages
+ await page.getByText('Errors exist for one or more form fields').waitFor();
+
+ // Close the form
+ await page.getByRole('button', { name: 'Cancel' }).click();
+});
diff --git a/src/frontend/tests/pui_command.spec.ts b/src/frontend/tests/pui_command.spec.ts
index 91ba60e795..4a2c30d3a9 100644
--- a/src/frontend/tests/pui_command.spec.ts
+++ b/src/frontend/tests/pui_command.spec.ts
@@ -15,7 +15,7 @@ test('Quick Command', async ({ page }) => {
await page.waitForURL('**/platform/dashboard');
// Open Spotlight with Button
- await page.getByRole('button', { name: 'Open spotlight' }).click();
+ await page.getByLabel('open-spotlight').click();
await page.getByRole('button', { name: 'Home Go to the home page' }).click();
await page
.getByRole('heading', { name: 'Welcome to your Dashboard,' })
@@ -35,7 +35,7 @@ test('Quick Command - No Keys', async ({ page }) => {
await doQuickLogin(page);
// Open Spotlight with Button
- await page.getByRole('button', { name: 'Open spotlight' }).click();
+ await page.getByLabel('open-spotlight').click();
await page.getByRole('button', { name: 'Home Go to the home page' }).click();
await page
.getByRole('heading', { name: 'Welcome to your Dashboard,' })
@@ -43,7 +43,7 @@ test('Quick Command - No Keys', async ({ page }) => {
await page.waitForURL('**/platform');
// Use navigation menu
- await page.getByRole('button', { name: 'Open spotlight' }).click();
+ await page.getByLabel('open-spotlight').click();
await page
.getByRole('button', { name: 'Open Navigation Open the main' })
.click();
@@ -56,7 +56,7 @@ test('Quick Command - No Keys', async ({ page }) => {
await page.keyboard.press('Escape');
// use server info
- await page.getByRole('button', { name: 'Open spotlight' }).click();
+ await page.getByLabel('open-spotlight').click();
await page
.getByRole('button', {
name: 'Server Information About this Inventree instance'
@@ -68,7 +68,7 @@ test('Quick Command - No Keys', async ({ page }) => {
await page.waitForURL('**/platform');
// use license info
- await page.getByRole('button', { name: 'Open spotlight' }).click();
+ await page.getByLabel('open-spotlight').click();
await page
.getByRole('button', {
name: 'License Information Licenses for dependencies of the service'
@@ -80,7 +80,7 @@ test('Quick Command - No Keys', async ({ page }) => {
await page.getByLabel('License Information').getByRole('button').click();
// use about
- await page.getByRole('button', { name: 'Open spotlight' }).click();
+ await page.getByLabel('open-spotlight').click();
await page
.getByRole('button', { name: 'About InvenTree About the InvenTree org' })
.click();
@@ -89,7 +89,7 @@ test('Quick Command - No Keys', async ({ page }) => {
await page.getByLabel('About InvenTree').getByRole('button').click();
// use documentation
- await page.getByRole('button', { name: 'Open spotlight' }).click();
+ await page.getByLabel('open-spotlight').click();
await page
.getByRole('button', {
name: 'Documentation Visit the documentation to learn more about InvenTree'
@@ -105,7 +105,7 @@ test('Quick Command - No Keys', async ({ page }) => {
/*
await page.getByPlaceholder('Search...').fill('secret');
await page.getByRole('button', { name: 'Secret action It was' }).click();
- await page.getByRole('button', { name: 'Open spotlight' }).click();
+ await page.getByLabel('open-spotlight').click();
await page.getByPlaceholder('Search...').fill('Another secret action');
await page
.getByRole('button', {
@@ -113,7 +113,7 @@ test('Quick Command - No Keys', async ({ page }) => {
})
.click();
await page.getByRole('tab', { name: 'Home' }).click();
- await page.getByRole('button', { name: 'Open spotlight' }).click();
+ await page.getByLabel('open-spotlight').click();
*/
await page.getByPlaceholder('Search...').fill('secret');
await page.getByText('Nothing found...').click();
diff --git a/src/frontend/tests/pui_stock.spec.ts b/src/frontend/tests/pui_stock.spec.ts
deleted file mode 100644
index d20b12cfa0..0000000000
--- a/src/frontend/tests/pui_stock.spec.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import { test } from './baseFixtures.js';
-import { baseUrl } from './defaults.js';
-import { doQuickLogin } from './login.js';
-
-test('Stock', async ({ page }) => {
- await doQuickLogin(page);
-
- await page.goto(`${baseUrl}/stock/location/index/`);
- await page.waitForURL('**/platform/stock/location/**');
-
- await page.getByRole('tab', { name: 'Location Details' }).click();
- await page.waitForURL('**/platform/stock/location/index/details');
-
- await page.getByRole('tab', { name: 'Stock Items' }).click();
- await page.getByText('1551ABK').first().click();
-
- await page.getByRole('tab', { name: 'Stock', exact: true }).click();
- await page.waitForURL('**/platform/stock/**');
- await page.getByRole('tab', { name: 'Stock Locations' }).click();
- await page.getByRole('cell', { name: 'Electronics Lab' }).first().click();
- await page.getByRole('tab', { name: 'Default Parts' }).click();
- await page.getByRole('tab', { name: 'Stock Locations' }).click();
- await page.getByRole('tab', { name: 'Stock Items' }).click();
- await page.getByRole('tab', { name: 'Location Details' }).click();
-
- await page.goto(`${baseUrl}/stock/item/1194/details`);
- await page.getByText('D.123 | Doohickey').waitFor();
- await page.getByText('Batch Code: BX-123-2024-2-7').waitFor();
- await page.getByRole('tab', { name: 'Stock Tracking' }).click();
- await page.getByRole('tab', { name: 'Test Data' }).click();
- await page.getByText('395c6d5586e5fb656901d047be27e1f7').waitFor();
- await page.getByRole('tab', { name: 'Installed Items' }).click();
-});
-
-test('Purchasing', async ({ page }) => {
- await doQuickLogin(page);
-
- await page.getByRole('tab', { name: 'Purchasing' }).click();
- await page.getByRole('cell', { name: 'PO0012' }).click();
- await page.waitForTimeout(200);
-
- await page.getByRole('tab', { name: 'Line Items' }).click();
- await page.getByRole('tab', { name: 'Received Stock' }).click();
- await page.getByRole('tab', { name: 'Attachments' }).click();
- await page.getByRole('tab', { name: 'Purchasing' }).click();
- await page.getByRole('tab', { name: 'Suppliers' }).click();
- await page.getByText('Arrow', { exact: true }).click();
- await page.waitForTimeout(200);
-
- await page.getByRole('tab', { name: 'Supplied Parts' }).click();
- await page.getByRole('tab', { name: 'Purchase Orders' }).click();
- await page.getByRole('tab', { name: 'Stock Items' }).click();
- await page.getByRole('tab', { name: 'Contacts' }).click();
- await page.getByRole('tab', { name: 'Addresses' }).click();
- await page.getByRole('tab', { name: 'Attachments' }).click();
- await page.getByRole('tab', { name: 'Purchasing' }).click();
- await page.getByRole('tab', { name: 'Manufacturers' }).click();
- await page.getByText('AVX Corporation').click();
- await page.waitForTimeout(200);
-
- await page.getByRole('tab', { name: 'Addresses' }).click();
- await page.getByRole('cell', { name: 'West Branch' }).click();
- await page.locator('.mantine-ScrollArea-root').click();
- await page
- .getByRole('row', { name: 'West Branch Yes Surf Avenue 9' })
- .getByRole('button')
- .click();
- await page.getByRole('menuitem', { name: 'Edit' }).click();
-
- await page.getByLabel('text-field-title').waitFor();
- await page.getByLabel('text-field-line2').waitFor();
-
- // Read the current value of the cell, to ensure we always *change* it!
- const value = await page.getByLabel('text-field-line2').inputValue();
- await page
- .getByLabel('text-field-line2')
- .fill(value == 'old' ? 'new' : 'old');
-
- await page.getByRole('button', { name: 'Submit' }).isEnabled();
-
- await page.getByRole('button', { name: 'Submit' }).click();
- await page.getByRole('tab', { name: 'Details' }).waitFor();
-});
-
-test('Stock Location Tree', async ({ page }) => {
- await doQuickLogin(page);
-
- await page.goto(`${baseUrl}/stock/location/index/`);
- await page.waitForURL('**/platform/stock/location/**');
- await page.getByRole('tab', { name: 'Location Details' }).click();
-
- await page.getByLabel('nav-breadcrumb-action').click();
- await page.getByLabel('nav-tree-toggle-1}').click();
- await page.getByLabel('nav-tree-item-2').click();
-
- await page.getByLabel('breadcrumb-2-storage-room-a').waitFor();
- await page.getByLabel('breadcrumb-1-factory').click();
-
- await page.getByRole('cell', { name: 'Factory' }).first().waitFor();
-});