2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 19:46:46 +00:00

[Refactor] Serial generation (#8246)

* Refactor "update_serial_number" method

* Refactor serial number validation

- Query is much more efficient now
- Does not have to check each serial number individually
- Makes use of existing Part class method

* Refactor creation of multiple stock items

* Fix for singular item creation

* Refactor serializeStock method:

- Push "rebuild tree" to background worker
- Use bulk_create actions

* Refactor createion of serialized build outputs

* Prevent 1+N DB hits

* Cleanup

* Cleanup

* Reinstate serial number checks

* Add limit for serial number extraction

* Fix cache config

* Revert cache settings

* Fix for unit tests

* Playwright tests

* Bug fix

* Force False cookie mode in testing

* Revert aria-label for PanelGroup items

- No longer works as expected with playwright locators

* Fix playwright vtest

* Further updates

* Playwright test adjustments

* Remove duplicate locator
This commit is contained in:
Oliver 2024-10-12 10:08:57 +11:00 committed by GitHub
parent f77c8a5b5b
commit 7443d21854
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 527 additions and 307 deletions

View File

@ -487,6 +487,11 @@ def extract_serial_numbers(input_string, expected_quantity: int, starting_value=
except ValueError: except ValueError:
raise ValidationError([_('Invalid quantity provided')]) 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 '' input_string = str(input_string).strip() if input_string else ''
if len(input_string) == 0: if len(input_string) == 0:

View File

@ -1101,7 +1101,7 @@ COOKIE_MODE = (
# Valid modes (as per the django settings documentation) # Valid modes (as per the django settings documentation)
valid_cookie_modes = ['lax', 'strict', 'none'] 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) # Set the cookie mode (in production mode only)
COOKIE_MODE = COOKIE_MODE.capitalize() COOKIE_MODE = COOKIE_MODE.capitalize()
else: else:

View File

@ -263,7 +263,9 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
MAX_QUERY_TIME = 7.5 MAX_QUERY_TIME = 7.5
@contextmanager @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. """Context manager to check that the number of queries is less than a certain value.
Example: Example:
@ -281,6 +283,12 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
f'Query count exceeded at {url}: Expected < {value} queries, got {n}' f'Query count exceeded at {url}: Expected < {value} queries, got {n}'
) # pragma: no cover ) # 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: if verbose and n >= value:
msg = f'\r\n{json.dumps(context.captured_queries, indent=4)}' # pragma: no cover msg = f'\r\n{json.dumps(context.captured_queries, indent=4)}' # pragma: no cover
else: else:

View File

@ -834,6 +834,16 @@ class Build(
location: Override location location: Override location
auto_allocate: Automatically allocate stock with matching serial numbers 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) user = kwargs.get('user', None)
batch = kwargs.get('batch', self.batch) batch = kwargs.get('batch', self.batch)
location = kwargs.get('location', None) location = kwargs.get('location', None)
@ -843,81 +853,51 @@ class Build(
if location is None: if location is None:
location = self.destination or self.part.get_default_location() location = self.destination or self.part.get_default_location()
""" # We are generating multiple serialized outputs
Determine if we can create a single output (with quantity > 0), if serials or self.part.has_trackable_parts:
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:
"""Create multiple build outputs with a single quantity of 1.""" """Create multiple build outputs with a single quantity of 1."""
# Quantity *must* be an integer at this point! # Create tracking entries for each item
quantity = int(quantity) 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: for output in outputs:
serial = serials[ii] # Generate a new historical tracking entry
else: if entry := output.add_tracking_entry(
serial = None 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( # Auto-allocate stock based on serial number
quantity=1, if auto_allocate:
location=location, allocations = []
part=self.part,
build=self,
batch=batch,
serial=serial,
is_building=True,
)
_add_tracking_entry(output, user) for bom_item in trackable_parts:
valid_part_ids = valid_parts.get(bom_item.pk, [])
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()
items = stock.models.StockItem.objects.filter( items = stock.models.StockItem.objects.filter(
part__in=parts, part__pk__in=valid_part_ids,
serial=str(serial), serial=output.serial,
quantity=1, quantity=1,
).filter(stock.models.StockItem.IN_STOCK_FILTER) ).filter(stock.models.StockItem.IN_STOCK_FILTER)
"""
Test if there is a matching serial number!
"""
if items.exists() and items.count() == 1: if items.exists() and items.count() == 1:
stock_item = items[0] stock_item = items[0]
@ -929,15 +909,23 @@ class Build(
) )
# Allocate the stock items against the BuildLine # Allocate the stock items against the BuildLine
BuildItem.objects.create( allocations.append(
build_line=build_line, BuildItem(
stock_item=stock_item, build_line=build_line,
quantity=1, stock_item=stock_item,
install_into=output, quantity=1,
install_into=output,
)
) )
except BuildLine.DoesNotExist: except BuildLine.DoesNotExist:
pass pass
# Bulk create tracking entries
stock.models.StockItemTracking.objects.bulk_create(tracking)
# Generate stock allocations
BuildItem.objects.bulk_create(allocations)
else: else:
"""Create a single build output of the given quantity.""" """Create a single build output of the given quantity."""
@ -950,7 +938,16 @@ class Build(
is_building=True 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: if self.status == BuildStatus.PENDING:
self.status = BuildStatus.PRODUCTION.value self.status = BuildStatus.PRODUCTION.value

View File

@ -404,11 +404,7 @@ class BuildOutputCreateSerializer(serializers.Serializer):
}) })
# Check for conflicting serial numbesr # Check for conflicting serial numbesr
existing = [] existing = part.find_conflicting_serial_numbers(self.serials)
for serial in self.serials:
if not part.validate_serial_number(serial):
existing.append(serial)
if len(existing) > 0: if len(existing) > 0:

View File

@ -833,12 +833,38 @@ class Part(
# This serial number is perfectly valid # This serial number is perfectly valid
return True 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.""" """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 = [] 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: 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) conflicts.append(serial)
return conflicts return conflicts

View File

@ -922,12 +922,12 @@ class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
data.update(self.clean_data(request.data)) data.update(self.clean_data(request.data))
quantity = data.get('quantity', None) quantity = data.get('quantity', None)
location = data.get('location', None)
if quantity is None: if quantity is None:
raise ValidationError({'quantity': _('Quantity is required')}) raise ValidationError({'quantity': _('Quantity is required')})
try: try:
Part.objects.prefetch_related(None)
part = Part.objects.get(pk=data.get('part', None)) part = Part.objects.get(pk=data.get('part', None))
except (ValueError, Part.DoesNotExist): except (ValueError, Part.DoesNotExist):
raise ValidationError({'part': _('Valid part must be supplied')}) raise ValidationError({'part': _('Valid part must be supplied')})
@ -951,15 +951,13 @@ class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
serials = None serials = None
# Check if a set of serial numbers was provided # 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 # 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: try:
supplier_part = SupplierPart.objects.get( supplier_part = SupplierPart.objects.get(pk=supplier_part_id)
pk=data.get('supplier_part', None) except Exception:
)
except (ValueError, SupplierPart.DoesNotExist):
raise ValidationError({ raise ValidationError({
'supplier_part': _('The given supplier part does not exist') '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 # Now remove the flag from data, so that it doesn't interfere with saving
# Do this regardless of results above # Do this regardless of results above
if 'use_pack_size' in data: data.pop('use_pack_size', None)
data.pop('use_pack_size')
# Assign serial numbers for a trackable part # Assign serial numbers for a trackable part
if serial_numbers: if serial_numbers:
@ -1011,22 +1008,20 @@ class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
invalid = [] invalid = []
errors = [] errors = []
for serial in serials: try:
try: invalid = part.find_conflicting_serial_numbers(serials)
part.validate_serial_number(serial, raise_error=True) except DjangoValidationError as exc:
except DjangoValidationError as exc: errors.append(exc.message)
# Catch raised error to extract specific error information
invalid.append(serial)
if exc.message not in errors: if len(invalid) > 0:
errors.append(exc.message)
if len(errors) > 0:
msg = _('The following serial numbers already exist or are invalid') msg = _('The following serial numbers already exist or are invalid')
msg += ' : ' msg += ' : '
msg += ','.join([str(e) for e in invalid]) 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: except DjangoValidationError as e:
raise ValidationError({ raise ValidationError({
@ -1043,34 +1038,43 @@ class StockList(DataExportViewMixin, ListCreateDestroyAPIView):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
with transaction.atomic(): with transaction.atomic():
# Create an initial StockItem object
item = serializer.save()
if serials: if serials:
# Assign the first serial number to the "master" item # Create multiple serialized StockItem objects
item.serial = serials[0] items = StockItem._create_serial_numbers(
serials, **serializer.validated_data
)
# Save the item (with user information) # Next, bulk-create stock tracking entries for the newly created items
item.save(user=user) tracking = []
if serials: for item in items:
for serial in serials[1:]: if entry := item.add_tracking_entry(
# Create a duplicate stock item with the next serial number StockHistoryCode.CREATED,
item.pk = None user,
item.serial = serial 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} response_data = {'quantity': quantity, 'serial_numbers': serials}
else: else:
# Create a single StockItem object
# Note: This automatically creates a tracking entry
item = serializer.save()
item.save(user=user)
response_data = serializer.data response_data = serializer.data
return Response( return Response(
response_data, response_data,
status=status.HTTP_201_CREATED, status=status.HTTP_201_CREATED,
headers=self.get_success_headers(serializer.data), headers=self.get_success_headers(serializer.data),
) )
def get_queryset(self, *args, **kwargs): def get_queryset(self, *args, **kwargs):
"""Annotate queryset before returning.""" """Annotate queryset before returning."""

View File

@ -12,7 +12,7 @@ from django.contrib.auth.models import User
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models, transaction 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.functions import Coalesce
from django.db.models.signals import post_delete, post_save, pre_delete from django.db.models.signals import post_delete, post_save, pre_delete
from django.db.utils import IntegrityError, OperationalError from django.db.utils import IntegrityError, OperationalError
@ -33,6 +33,7 @@ import InvenTree.ready
import InvenTree.tasks import InvenTree.tasks
import report.mixins import report.mixins
import report.models import report.models
import stock.tasks
from build import models as BuildModels from build import models as BuildModels
from common.icons import validate_icon from common.icons import validate_icon
from common.settings import get_global_setting from common.settings import get_global_setting
@ -459,6 +460,97 @@ class StockItem(
& Q(expiry_date__lt=InvenTree.helpers.current_date()) & 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): def update_serial_number(self):
"""Update the 'serial_int' field, to be an integer representation of the serial number. """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() 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 try:
# If a non-null value is returned (by any plugin) we will use that serial_int = int(serial_int)
serial_int = None if serial_int <= 0:
serial_int = 0
for plugin in registry.with_mixin('validation'): except (ValueError, TypeError):
serial_int = plugin.convert_serial_to_int(serial) serial_int = 0
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)
self.serial_int = serial_int self.serial_int = serial_int
@ -1452,6 +1524,7 @@ class StockItem(
user: User, user: User,
deltas: dict | None = None, deltas: dict | None = None,
notes: str = '', notes: str = '',
commit: bool = True,
**kwargs, **kwargs,
): ):
"""Add a history tracking entry for this StockItem. """Add a history tracking entry for this StockItem.
@ -1461,6 +1534,9 @@ class StockItem(
user (User): The user performing this action user (User): The user performing this action
deltas (dict, optional): A map of the changes made to the model. Defaults to None. 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 ''. notes (str, optional): URL associated with this tracking entry. Defaults to ''.
Returns:
StockItemTracking: The created tracking entry
""" """
if deltas is None: if deltas is None:
deltas = {} deltas = {}
@ -1471,7 +1547,7 @@ class StockItem(
and len(deltas) == 0 and len(deltas) == 0
and not notes and not notes
): ):
return return None
# Has a location been specified? # Has a location been specified?
location = kwargs.get('location') location = kwargs.get('location')
@ -1485,7 +1561,7 @@ class StockItem(
if quantity: if quantity:
deltas['quantity'] = float(quantity) deltas['quantity'] = float(quantity)
entry = StockItemTracking.objects.create( entry = StockItemTracking(
item=self, item=self,
tracking_type=entry_type.value, tracking_type=entry_type.value,
user=user, user=user,
@ -1494,7 +1570,10 @@ class StockItem(
deltas=deltas, deltas=deltas,
) )
entry.save() if commit:
entry.save()
return entry
@transaction.atomic @transaction.atomic
def serializeStock(self, quantity, serials, user, notes='', location=None): def serializeStock(self, quantity, serials, user, notes='', location=None):
@ -1536,7 +1615,7 @@ class StockItem(
if type(serials) not in [list, tuple]: if type(serials) not in [list, tuple]:
raise ValidationError({ 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): if quantity != len(serials):
@ -1552,45 +1631,54 @@ class StockItem(
msg = _('Serial numbers already exist') + f': {exists}' msg = _('Serial numbers already exist') + f': {exists}'
raise ValidationError({'serial_numbers': msg}) raise ValidationError({'serial_numbers': msg})
# Create a new stock item for each unique serial number # Serialize this StockItem
for serial in serials: data = dict(StockItem.objects.filter(pk=self.pk).values()[0])
# 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
if location: if location:
new_item.location = location data['location'] = location
# The item already has a transaction history, don't create a new note data['part'] = self.part
new_item.save(user=user, notes=notes) data['parent'] = self
data['tree_id'] = self.tree_id
# Copy entire transaction history # Generate a new serial number for each item
new_item.copyHistoryFrom(self) items = StockItem._create_serial_numbers(serials, **data)
# Copy test result history # Create a new tracking entry for each item
new_item.copyTestResultsFrom(self) history_items = []
# Create a new stock tracking item for item in items:
new_item.add_tracking_entry( if entry := item.add_tracking_entry(
StockHistoryCode.ASSIGNED_SERIAL, StockHistoryCode.ASSIGNED_SERIAL,
user, user,
notes=notes, notes=notes,
deltas={'serial': serial}, deltas={'serial': item.serial},
location=location, 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 # Remove the equivalent number of items
self.take_stock(quantity, user, notes=notes) self.take_stock(quantity, user, notes=notes)
# Rebuild the stock tree # Rebuild the stock tree
try: InvenTree.tasks.offload_task(
StockItem.objects.partial_rebuild(tree_id=self.tree_id) stock.tasks.rebuild_stock_item_tree, tree_id=self.tree_id
except Exception: )
logger.warning('Failed to rebuild stock tree during serializeStock')
StockItem.objects.rebuild()
@transaction.atomic @transaction.atomic
def copyHistoryFrom(self, other): def copyHistoryFrom(self, other):
@ -1822,12 +1910,10 @@ class StockItem(
self.save() self.save()
# Rebuild stock trees as required # Rebuild stock trees as required
try: for tree_id in tree_ids:
for tree_id in tree_ids: InvenTree.tasks.offload_task(
StockItem.objects.partial_rebuild(tree_id=tree_id) stock.tasks.rebuild_stock_item_tree, tree_id=tree_id
except Exception: )
logger.warning('Rebuilding entire StockItem tree during merge_stock_items')
StockItem.objects.rebuild()
@transaction.atomic @transaction.atomic
def splitStock(self, quantity, location=None, user=None, **kwargs): def splitStock(self, quantity, location=None, user=None, **kwargs):
@ -1922,11 +2008,9 @@ class StockItem(
) )
# Rebuild the tree for this parent item # Rebuild the tree for this parent item
try: InvenTree.tasks.offload_task(
StockItem.objects.partial_rebuild(tree_id=self.tree_id) stock.tasks.rebuild_stock_item_tree, tree_id=self.tree_id
except Exception: )
logger.warning('Rebuilding entire StockItem tree')
StockItem.objects.rebuild()
# Attempt to reload the new item from the database # Attempt to reload the new item from the database
try: try:

View File

@ -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()

View File

@ -2406,7 +2406,7 @@ class StockStatisticsTest(StockAPITestCase):
fixtures = [*StockAPITestCase.fixtures, 'build'] fixtures = [*StockAPITestCase.fixtures, 'build']
def test_test_statics(self): def test_test_statistics(self):
"""Test the test statistics API endpoints.""" """Test the test statistics API endpoints."""
part = Part.objects.first() part = Part.objects.first()
response = self.get( response = self.get(

View File

@ -13,6 +13,7 @@ export function SpotlightButton() {
onClick={() => firstSpotlight.open()} onClick={() => firstSpotlight.open()}
title={t`Open spotlight`} title={t`Open spotlight`}
variant="transparent" variant="transparent"
aria-label="open-spotlight"
> >
<IconCommand /> <IconCommand />
</ActionIcon> </ActionIcon>

View File

@ -103,7 +103,11 @@ export function Header() {
<NavTabs /> <NavTabs />
</Group> </Group>
<Group> <Group>
<ActionIcon onClick={openSearchDrawer} variant="transparent"> <ActionIcon
onClick={openSearchDrawer}
variant="transparent"
aria-label="open-search"
>
<IconSearch /> <IconSearch />
</ActionIcon> </ActionIcon>
<SpotlightButton /> <SpotlightButton />
@ -119,6 +123,7 @@ export function Header() {
<ActionIcon <ActionIcon
onClick={openNotificationDrawer} onClick={openNotificationDrawer}
variant="transparent" variant="transparent"
aria-label="open-notifications"
> >
<IconBell /> <IconBell />
</ActionIcon> </ActionIcon>

View File

@ -68,7 +68,13 @@ function QueryResultGroup({
const model = getModelInfo(query.model); const model = getModelInfo(query.model);
return ( return (
<Paper shadow="sm" radius="xs" p="md" key={`paper-${query.model}`}> <Paper
shadow="sm"
radius="xs"
p="md"
key={`paper-${query.model}`}
aria-label={`search-group-${query.model}`}
>
<Stack key={`stack-${query.model}`}> <Stack key={`stack-${query.model}`}>
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Group justify="left" gap={5} wrap="nowrap"> <Group justify="left" gap={5} wrap="nowrap">
@ -84,13 +90,14 @@ function QueryResultGroup({
color="red" color="red"
variant="transparent" variant="transparent"
radius="xs" radius="xs"
aria-label={`remove-search-group-${query.model}`}
onClick={() => onRemove(query.model)} onClick={() => onRemove(query.model)}
> >
<IconX /> <IconX />
</ActionIcon> </ActionIcon>
</Group> </Group>
<Divider /> <Divider />
<Stack> <Stack aria-label={`search-group-results-${query.model}`}>
{query.results.results.map((result: any) => ( {query.results.results.map((result: any) => (
<Anchor <Anchor
onClick={(event: any) => onClick={(event: any) =>
@ -367,6 +374,7 @@ export function SearchDrawer({
title={ title={
<Group justify="space-between" gap={1} wrap="nowrap"> <Group justify="space-between" gap={1} wrap="nowrap">
<TextInput <TextInput
aria-label="global-search-input"
placeholder={t`Enter search text`} placeholder={t`Enter search text`}
radius="xs" radius="xs"
value={value} value={value}

View File

@ -127,9 +127,14 @@ function BasePanelGroup({
return ( return (
<Boundary label={`PanelGroup-${pageKey}`}> <Boundary label={`PanelGroup-${pageKey}`}>
<Paper p="sm" radius="xs" shadow="xs"> <Paper p="sm" radius="xs" shadow="xs" aria-label={`${pageKey}`}>
<Tabs value={currentPanel} orientation="vertical" keepMounted={false}> <Tabs
<Tabs.List justify="left"> value={currentPanel}
orientation="vertical"
keepMounted={false}
aria-label={`panel-group-${pageKey}`}
>
<Tabs.List justify="left" aria-label={`panel-tabs-${pageKey}`}>
{allPanels.map( {allPanels.map(
(panel) => (panel) =>
!panel.hidden && ( !panel.hidden && (

View File

@ -50,8 +50,10 @@ import { useGlobalSettingsState } from '../states/SettingsState';
export function useStockFields({ export function useStockFields({
item_detail, item_detail,
part_detail, part_detail,
partId,
create = false create = false
}: { }: {
partId?: number;
item_detail?: any; item_detail?: any;
part_detail?: any; part_detail?: any;
create: boolean; create: boolean;
@ -81,7 +83,7 @@ export function useStockFields({
return useMemo(() => { return useMemo(() => {
const fields: ApiFormFieldSet = { const fields: ApiFormFieldSet = {
part: { part: {
value: part, value: partId,
disabled: !create, disabled: !create,
filters: { filters: {
active: create ? true : undefined active: create ? true : undefined
@ -201,7 +203,8 @@ export function useStockFields({
batchCode, batchCode,
serialNumbers, serialNumbers,
trackable, trackable,
create create,
partId
]); ]);
} }

View File

@ -401,7 +401,7 @@ export function StockItemTable({
}; };
}, [table]); }, [table]);
const stockItemFields = useStockFields({ create: true }); const stockItemFields = useStockFields({ create: true, partId: params.part });
const newStockItem = useCreateApiFormModal({ const newStockItem = useCreateApiFormModal({
url: ApiEndpoints.stock_item_list, url: ApiEndpoints.stock_item_list,

View File

@ -5,7 +5,7 @@ test('Modals as admin', async ({ page }) => {
await doQuickLogin(page, 'admin', 'inventree'); await doQuickLogin(page, 'admin', 'inventree');
// use server info // use server info
await page.getByRole('button', { name: 'Open spotlight' }).click(); await page.getByLabel('open-spotlight').click();
await page await page
.getByRole('button', { .getByRole('button', {
name: 'Server Information About this Inventree instance' name: 'Server Information About this Inventree instance'
@ -17,7 +17,7 @@ test('Modals as admin', async ({ page }) => {
await page.waitForURL('**/platform/home'); await page.waitForURL('**/platform/home');
// use license info // use license info
await page.getByRole('button', { name: 'Open spotlight' }).click(); await page.getByLabel('open-spotlight').click();
await page await page
.getByRole('button', { .getByRole('button', {
name: 'License Information Licenses for dependencies of the service' name: 'License Information Licenses for dependencies of the service'
@ -44,7 +44,7 @@ test('Modals as admin', async ({ page }) => {
.click(); .click();
// use about // use about
await page.getByRole('button', { name: 'Open spotlight' }).click(); await page.getByLabel('open-spotlight').click();
await page await page
.getByRole('button', { name: 'About InvenTree About the InvenTree org' }) .getByRole('button', { name: 'About InvenTree About the InvenTree org' })
.click(); .click();

View File

@ -178,3 +178,53 @@ test('Purchase Orders - Barcodes', async ({ page }) => {
await page.waitForTimeout(500); await page.waitForTimeout(500);
await page.getByRole('button', { name: 'Issue Order' }).waitFor(); 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();
});

View File

@ -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();
});

View File

@ -15,7 +15,7 @@ test('Quick Command', async ({ page }) => {
await page.waitForURL('**/platform/dashboard'); await page.waitForURL('**/platform/dashboard');
// Open Spotlight with Button // 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('button', { name: 'Home Go to the home page' }).click();
await page await page
.getByRole('heading', { name: 'Welcome to your Dashboard,' }) .getByRole('heading', { name: 'Welcome to your Dashboard,' })
@ -35,7 +35,7 @@ test('Quick Command - No Keys', async ({ page }) => {
await doQuickLogin(page); await doQuickLogin(page);
// Open Spotlight with Button // 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('button', { name: 'Home Go to the home page' }).click();
await page await page
.getByRole('heading', { name: 'Welcome to your Dashboard,' }) .getByRole('heading', { name: 'Welcome to your Dashboard,' })
@ -43,7 +43,7 @@ test('Quick Command - No Keys', async ({ page }) => {
await page.waitForURL('**/platform'); await page.waitForURL('**/platform');
// Use navigation menu // Use navigation menu
await page.getByRole('button', { name: 'Open spotlight' }).click(); await page.getByLabel('open-spotlight').click();
await page await page
.getByRole('button', { name: 'Open Navigation Open the main' }) .getByRole('button', { name: 'Open Navigation Open the main' })
.click(); .click();
@ -56,7 +56,7 @@ test('Quick Command - No Keys', async ({ page }) => {
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
// use server info // use server info
await page.getByRole('button', { name: 'Open spotlight' }).click(); await page.getByLabel('open-spotlight').click();
await page await page
.getByRole('button', { .getByRole('button', {
name: 'Server Information About this Inventree instance' name: 'Server Information About this Inventree instance'
@ -68,7 +68,7 @@ test('Quick Command - No Keys', async ({ page }) => {
await page.waitForURL('**/platform'); await page.waitForURL('**/platform');
// use license info // use license info
await page.getByRole('button', { name: 'Open spotlight' }).click(); await page.getByLabel('open-spotlight').click();
await page await page
.getByRole('button', { .getByRole('button', {
name: 'License Information Licenses for dependencies of the service' 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(); await page.getByLabel('License Information').getByRole('button').click();
// use about // use about
await page.getByRole('button', { name: 'Open spotlight' }).click(); await page.getByLabel('open-spotlight').click();
await page await page
.getByRole('button', { name: 'About InvenTree About the InvenTree org' }) .getByRole('button', { name: 'About InvenTree About the InvenTree org' })
.click(); .click();
@ -89,7 +89,7 @@ test('Quick Command - No Keys', async ({ page }) => {
await page.getByLabel('About InvenTree').getByRole('button').click(); await page.getByLabel('About InvenTree').getByRole('button').click();
// use documentation // use documentation
await page.getByRole('button', { name: 'Open spotlight' }).click(); await page.getByLabel('open-spotlight').click();
await page await page
.getByRole('button', { .getByRole('button', {
name: 'Documentation Visit the documentation to learn more about InvenTree' 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.getByPlaceholder('Search...').fill('secret');
await page.getByRole('button', { name: 'Secret action It was' }).click(); 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.getByPlaceholder('Search...').fill('Another secret action');
await page await page
.getByRole('button', { .getByRole('button', {
@ -113,7 +113,7 @@ test('Quick Command - No Keys', async ({ page }) => {
}) })
.click(); .click();
await page.getByRole('tab', { name: 'Home' }).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.getByPlaceholder('Search...').fill('secret');
await page.getByText('Nothing found...').click(); await page.getByText('Nothing found...').click();

View File

@ -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();
});