From 7443d2185434fa224944154c27ea1456c12ce38a Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 12 Oct 2024 10:08:57 +1100 Subject: [PATCH] [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 --- src/backend/InvenTree/InvenTree/helpers.py | 5 + src/backend/InvenTree/InvenTree/settings.py | 2 +- src/backend/InvenTree/InvenTree/unit_test.py | 10 +- src/backend/InvenTree/build/models.py | 137 ++++++----- src/backend/InvenTree/build/serializers.py | 6 +- src/backend/InvenTree/part/models.py | 30 ++- src/backend/InvenTree/stock/api.py | 80 ++++--- src/backend/InvenTree/stock/models.py | 220 ++++++++++++------ src/backend/InvenTree/stock/tasks.py | 24 ++ src/backend/InvenTree/stock/test_api.py | 2 +- .../components/buttons/SpotlightButton.tsx | 1 + src/frontend/src/components/nav/Header.tsx | 7 +- .../src/components/nav/SearchDrawer.tsx | 12 +- .../src/components/panels/PanelGroup.tsx | 11 +- src/frontend/src/forms/StockForms.tsx | 7 +- .../src/tables/stock/StockItemTable.tsx | 2 +- src/frontend/tests/modals.spec.ts | 6 +- src/frontend/tests/pages/pui_orders.spec.ts | 50 ++++ src/frontend/tests/pages/pui_stock.spec.ts | 104 +++++++++ src/frontend/tests/pui_command.spec.ts | 18 +- src/frontend/tests/pui_stock.spec.ts | 100 -------- 21 files changed, 527 insertions(+), 307 deletions(-) create mode 100644 src/backend/InvenTree/stock/tasks.py create mode 100644 src/frontend/tests/pages/pui_stock.spec.ts delete mode 100644 src/frontend/tests/pui_stock.spec.ts 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(); -});