mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 05:05:42 +00:00 
			
		
		
		
	[bug] Tree fix (#9979)
* Refactor InvenTreeTree model structure - Allow for tree with null items * Refactor pathstring * Factor pathstring out into a separate mixin - Keep tree operations separate (in InvenTreeTree) - Pathstring operations are now in PathStringMixin * throw error - Ensure that this func gets removed in future commit * Fix node delete code * Migrate "Build" model to new structure * Add unit tests for Build tree structure * Refactor StockLocationTreeTest * Implement tree rebuild test for StockItem model * Add unit test for stock item serialization * Refactor "Part" model to use mixin * Add unit tests for part variant tree * Add test for node deletion * Adjust unit tests * Ensure items are not created with null tree_id * Further unit tests and updates * Fix unit tests * Remove duplicate check * Adjust build fixture * Remove rebuild call * Fixing more tests * Remove calls to rebuild part tree * Add test for tree fixtures * Report tree rebuild errors to sentry * Remove helper func * Updates for splitStock * Cleaner inheritance * Simpilfy test - tree_id is somewhat ephemeral * Handle null parent * Enforce partial rebuild if parent changes * Fix * Remove hard-coded "parent" references * Fix order of delete operations * Fix unit test * Unit test tweaks * Improved handling for deleting a root node * Only set tree_id if not already specified * Only rebuild valid tree_id values * Cast to list * Adjust unit test - Test values were wrong, due to bad data in fixtures * Do not bulk delete - mysql no likey * Enhanced rebuild logic * Fix for unit test * Improve logic for _create_serial_numbers * Unit test fix * Remove unused function
This commit is contained in:
		| @@ -154,7 +154,7 @@ def generateTestKey(test_name: str) -> str: | ||||
|     return key | ||||
|  | ||||
|  | ||||
| def constructPathString(path, max_chars=250): | ||||
| def constructPathString(path: list[str], max_chars: int = 250) -> str: | ||||
|     """Construct a 'path string' for the given path. | ||||
|  | ||||
|     Arguments: | ||||
|   | ||||
| @@ -4,7 +4,6 @@ from datetime import datetime | ||||
| from string import Formatter | ||||
|  | ||||
| from django.contrib.auth import get_user_model | ||||
| from django.contrib.contenttypes.models import ContentType | ||||
| from django.core.exceptions import ValidationError | ||||
| from django.db import models | ||||
| from django.db.models import QuerySet | ||||
| @@ -26,6 +25,7 @@ import InvenTree.fields | ||||
| import InvenTree.format | ||||
| import InvenTree.helpers | ||||
| import InvenTree.helpers_model | ||||
| import InvenTree.sentry | ||||
|  | ||||
| logger = structlog.get_logger('inventree') | ||||
|  | ||||
| @@ -123,7 +123,7 @@ class PluginValidationMixin(DiffMixin): | ||||
|         self.run_plugin_validation() | ||||
|         super().save(*args, **kwargs) | ||||
|  | ||||
|     def delete(self): | ||||
|     def delete(self, *args, **kwargs): | ||||
|         """Run plugin validation on model delete. | ||||
|  | ||||
|         Allows plugins to prevent model instances from being deleted. | ||||
| @@ -143,7 +143,7 @@ class PluginValidationMixin(DiffMixin): | ||||
|                 log_error('validate_model_deletion', plugin=plugin.slug) | ||||
|                 continue | ||||
|  | ||||
|         super().delete() | ||||
|         super().delete(*args, **kwargs) | ||||
|  | ||||
|  | ||||
| class MetadataMixin(models.Model): | ||||
| @@ -474,13 +474,13 @@ class InvenTreeAttachmentMixin: | ||||
|     - attachments: Return a queryset containing all attachments for this model | ||||
|     """ | ||||
|  | ||||
|     def delete(self): | ||||
|     def delete(self, *args, **kwargs): | ||||
|         """Handle the deletion of a model instance. | ||||
|  | ||||
|         Before deleting the model instance, delete any associated attachments. | ||||
|         """ | ||||
|         self.attachments.all().delete() | ||||
|         super().delete() | ||||
|         super().delete(*args, **kwargs) | ||||
|  | ||||
|     @property | ||||
|     def attachments(self): | ||||
| @@ -525,44 +525,51 @@ class InvenTreeAttachmentMixin: | ||||
|         Attachment.objects.create(**kwargs) | ||||
|  | ||||
|  | ||||
| class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel): | ||||
|     """Provides an abstracted self-referencing tree model for data categories. | ||||
| class InvenTreeTree(MPTTModel): | ||||
|     """Provides an abstracted self-referencing tree model, based on the MPTTModel class. | ||||
|  | ||||
|     - Each Category has one parent Category, which can be blank (for a top-level Category). | ||||
|     - Each Category can have zero-or-more child Category(y/ies) | ||||
|     Our implementation provides the following key improvements: | ||||
|  | ||||
|     Attributes: | ||||
|         name: brief name | ||||
|         description: longer form description | ||||
|         parent: The item immediately above this one. An item with a null parent is a top-level item | ||||
|     - Allow tracking of separate concepts of "nodes" and "items" | ||||
|     - Better handling of deletion of nodes and items | ||||
|     - Ensure tree is correctly rebuilt after deletion and other operations | ||||
|     - Improved protection against recursive tree structures | ||||
|     """ | ||||
|  | ||||
|     # How each node reference its parent object | ||||
|     NODE_PARENT_KEY = 'parent' | ||||
|  | ||||
|     # How items (not nodes) are hooked into the tree | ||||
|     # e.g. for StockLocation, this value is 'location' | ||||
|     ITEM_PARENT_KEY = None | ||||
|  | ||||
|     # Extra fields to include in the get_path result. E.g. icon | ||||
|     EXTRA_PATH_FIELDS = [] | ||||
|  | ||||
|     class Meta: | ||||
|         """Metaclass defines extra model properties.""" | ||||
|  | ||||
|         abstract = True | ||||
|  | ||||
|     class MPTTMeta: | ||||
|         """Set insert order.""" | ||||
|         """MPTT metaclass options.""" | ||||
|  | ||||
|         order_insertion_by = ['name'] | ||||
|  | ||||
|     def delete(self, delete_children=False, delete_items=False): | ||||
|     def delete(self, *args, **kwargs): | ||||
|         """Handle the deletion of a tree node. | ||||
|  | ||||
|         1. Update nodes and items under the current node | ||||
|         2. Delete this node | ||||
|         3. Rebuild the model tree | ||||
|         4. Rebuild the path for any remaining lower nodes | ||||
|         kwargs: | ||||
|             delete_children: If True, delete all child nodes (otherwise, point to the parent of this node) | ||||
|             delete_items: If True, delete all items associated with this node (otherwise, point to the parent of this node) | ||||
|  | ||||
|         Order of operations: | ||||
|             1. Update nodes and items under the current node | ||||
|             2. Delete this node | ||||
|             3. Rebuild the model tree | ||||
|         """ | ||||
|         tree_id = self.tree_id if self.parent else None | ||||
|         delete_children = kwargs.pop('delete_children', False) | ||||
|         delete_items = kwargs.pop('delete_items', False) | ||||
|  | ||||
|         tree_id = self.tree_id | ||||
|         parent = getattr(self, self.NODE_PARENT_KEY, None) | ||||
|  | ||||
|         # Ensure that we have the latest version of the database object | ||||
|         try: | ||||
| @@ -573,10 +580,20 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel): | ||||
|                 'Object %s of type %s no longer exists', str(self), str(self.__class__) | ||||
|             ) | ||||
|  | ||||
|         # Cache node ID values for lower nodes, before we delete this one | ||||
|         lower_nodes = list( | ||||
|             self.get_descendants(include_self=False).values_list('pk', flat=True) | ||||
|         ) | ||||
|         # When deleting a top level node with multiple children, | ||||
|         # we need to assign a new tree_id to each child node | ||||
|         # otherwise they will all have the same tree_id (which is not allowed) | ||||
|         lower_trees = [] | ||||
|  | ||||
|         if not parent:  # No parent, which means this is a top-level node | ||||
|             for child in self.get_children(): | ||||
|                 # Store a flattened list of node IDs for each of the lower trees | ||||
|                 nodes = list( | ||||
|                     child.get_descendants(include_self=True) | ||||
|                     .values_list('pk', flat=True) | ||||
|                     .distinct() | ||||
|                 ) | ||||
|                 lower_trees.append(nodes) | ||||
|  | ||||
|         # 1. Update nodes and items under the current node | ||||
|         self.handle_tree_delete( | ||||
| @@ -584,49 +601,48 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel): | ||||
|         ) | ||||
|  | ||||
|         # 2. Delete *this* node | ||||
|         super().delete() | ||||
|         super().delete(*args, **kwargs) | ||||
|  | ||||
|         # A set of tree_id values which need to be rebuilt | ||||
|         trees = set() | ||||
|  | ||||
|         # 3. Update the tree structure | ||||
|         if tree_id: | ||||
|             try: | ||||
|                 self.__class__.objects.partial_rebuild(tree_id) | ||||
|             except Exception: | ||||
|                 InvenTree.exceptions.log_error( | ||||
|                     f'{self.__class__.__name__}.partial_rebuild' | ||||
|                 ) | ||||
|                 logger.warning( | ||||
|                     'Failed to rebuild tree for %s <%s>', | ||||
|                     self.__class__.__name__, | ||||
|                     self.pk, | ||||
|                 ) | ||||
|                 # If the partial rebuild fails, rebuild the entire tree | ||||
|                 self.__class__.objects.rebuild() | ||||
|         else: | ||||
|             # If this node had a tree_id, we need to rebuild that tree | ||||
|             trees.add(tree_id) | ||||
|  | ||||
|         # Did we delete a top-level node? | ||||
|         next_tree_id = self.getNextTreeID() | ||||
|  | ||||
|         # If there is only one sub-tree, it can retain the same tree_id value | ||||
|         for tree in lower_trees[1:]: | ||||
|             # Bulk update the tree_id for all lower nodes | ||||
|             lower_nodes = self.__class__.objects.filter(pk__in=tree) | ||||
|             lower_nodes.update(tree_id=next_tree_id) | ||||
|             trees.add(next_tree_id) | ||||
|             next_tree_id += 1 | ||||
|  | ||||
|         # 3. Rebuild the model tree(s) as required | ||||
|         #  - If any partial rebuilds fail, we will rebuild the entire tree | ||||
|  | ||||
|         result = True | ||||
|  | ||||
|         for tree_id in trees: | ||||
|             if tree_id: | ||||
|                 if not self.partial_rebuild(tree_id): | ||||
|                     result = False | ||||
|  | ||||
|         if not result: | ||||
|             # Rebuild the entire tree (expensive!!!) | ||||
|             self.__class__.objects.rebuild() | ||||
|  | ||||
|         # 4. Rebuild the path for any remaining lower nodes | ||||
|         nodes = self.__class__.objects.filter(pk__in=lower_nodes) | ||||
|  | ||||
|         nodes_to_update = [] | ||||
|  | ||||
|         for node in nodes: | ||||
|             new_path = node.construct_pathstring() | ||||
|  | ||||
|             if new_path != node.pathstring: | ||||
|                 node.pathstring = new_path | ||||
|                 nodes_to_update.append(node) | ||||
|  | ||||
|         if len(nodes_to_update) > 0: | ||||
|             self.__class__.objects.bulk_update(nodes_to_update, ['pathstring']) | ||||
|  | ||||
|     def handle_tree_delete(self, delete_children=False, delete_items=False): | ||||
|         """Delete a single instance of the tree, based on provided kwargs. | ||||
|  | ||||
|         Removing a tree "node" from the database must be considered carefully, | ||||
|         based on what the user intends for any items which exist *under* that node. | ||||
|  | ||||
|         - "children" are any nodes which exist *under* this node (e.g. PartCategory) | ||||
|         - "items" are any items which exist *under* this node (e.g. Part) | ||||
|         - "children" are any nodes (of the same type) which exist *under* this node (e.g. PartCategory) | ||||
|         - "items" are any items (of a different type) which exist *under* this node (e.g. Part) | ||||
|  | ||||
|         Arguments: | ||||
|             delete_children: If True, delete all child items | ||||
| @@ -645,30 +661,34 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel): | ||||
|         # - Delete all items at any lower level | ||||
|         # - Delete all descendant nodes | ||||
|         if delete_children and delete_items: | ||||
|             self.get_items(cascade=True).delete() | ||||
|             self.delete_items(cascade=True) | ||||
|             self.delete_nodes(child_nodes) | ||||
|  | ||||
|         # Case B: Delete all child nodes, but move all child items up to the parent | ||||
|         # - Move all items at any lower level to the parent of this item | ||||
|         # - Delete all descendant nodes | ||||
|         elif delete_children and not delete_items: | ||||
|             self.get_items(cascade=True).update(**{self.ITEM_PARENT_KEY: self.parent}) | ||||
|  | ||||
|             if items := self.get_items(cascade=True): | ||||
|                 parent = getattr(self, self.NODE_PARENT_KEY, None) | ||||
|                 items.update(**{self.ITEM_PARENT_KEY: parent}) | ||||
|             self.delete_nodes(child_nodes) | ||||
|  | ||||
|         # Case C: Delete all child items, but keep all child nodes | ||||
|         # - Remove all items directly associated with this node | ||||
|         # - Move any direct child nodes up one level | ||||
|         elif not delete_children and delete_items: | ||||
|             self.get_items(cascade=False).delete() | ||||
|             self.get_children().update(parent=self.parent) | ||||
|             self.delete_items(cascade=False) | ||||
|             parent = getattr(self, self.NODE_PARENT_KEY, None) | ||||
|             self.get_children().update(**{self.NODE_PARENT_KEY: parent}) | ||||
|  | ||||
|         # Case D: Keep all child items, and keep all child nodes | ||||
|         # - Move all items directly associated with this node up one level | ||||
|         # - Move any direct child nodes up one level | ||||
|         elif not delete_children and not delete_items: | ||||
|             self.get_items(cascade=False).update(**{self.ITEM_PARENT_KEY: self.parent}) | ||||
|             self.get_children().update(parent=self.parent) | ||||
|             parent = getattr(self, self.NODE_PARENT_KEY, None) | ||||
|             if items := self.get_items(cascade=False): | ||||
|                 items.update(**{self.ITEM_PARENT_KEY: parent}) | ||||
|             self.get_children().update(**{self.NODE_PARENT_KEY: parent}) | ||||
|  | ||||
|     def delete_nodes(self, nodes): | ||||
|         """Delete  a set of nodes from the tree. | ||||
| @@ -681,67 +701,144 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel): | ||||
|         Arguments: | ||||
|             nodes: A queryset of nodes to delete | ||||
|         """ | ||||
|         nodes.update(parent=None) | ||||
|         nodes.update(**{self.NODE_PARENT_KEY: None}) | ||||
|         nodes.delete() | ||||
|  | ||||
|     def validate_unique(self, exclude=None): | ||||
|         """Validate that this tree instance satisfies our uniqueness requirements. | ||||
|  | ||||
|         Note that a 'unique_together' requirement for ('name', 'parent') is insufficient, | ||||
|         as it ignores cases where parent=None (i.e. top-level items) | ||||
|         """ | ||||
|         super().validate_unique(exclude) | ||||
|  | ||||
|         results = self.__class__.objects.filter( | ||||
|             name=self.name, parent=self.parent | ||||
|         ).exclude(pk=self.pk) | ||||
|  | ||||
|         if results.exists(): | ||||
|             raise ValidationError({ | ||||
|                 'name': _('Duplicate names cannot exist under the same parent') | ||||
|             }) | ||||
|  | ||||
|     def api_instance_filters(self): | ||||
|         """Instance filters for InvenTreeTree models.""" | ||||
|         return {'parent': {'exclude_tree': self.pk}} | ||||
|  | ||||
|     def construct_pathstring(self): | ||||
|         """Construct the pathstring for this tree node.""" | ||||
|         return InvenTree.helpers.constructPathString([item.name for item in self.path]) | ||||
|         return {self.NODE_PARENT_KEY: {'exclude_tree': self.pk}} | ||||
|  | ||||
|     def save(self, *args, **kwargs): | ||||
|         """Custom save method for InvenTreeTree abstract model.""" | ||||
|         db_instance = None | ||||
|  | ||||
|         parent = getattr(self, self.NODE_PARENT_KEY, None) | ||||
|  | ||||
|         if not self.tree_id: | ||||
|             if parent: | ||||
|                 # If we have a parent, use the parent's tree_id | ||||
|                 self.tree_id = parent.tree_id | ||||
|             else: | ||||
|                 # Otherwise, we need to generate a new tree_id | ||||
|                 self.tree_id = self.getNextTreeID() | ||||
|  | ||||
|         if self.pk: | ||||
|             try: | ||||
|                 db_instance = self.get_db_instance() | ||||
|             except self.__class__.DoesNotExist: | ||||
|                 # If the instance does not exist, we cannot get the db instance | ||||
|                 db_instance = None | ||||
|         try: | ||||
|             super().save(*args, **kwargs) | ||||
|         except InvalidMove: | ||||
|             # Provide better error for parent selection | ||||
|             raise ValidationError({'parent': _('Invalid choice')}) | ||||
|             raise ValidationError({self.NODE_PARENT_KEY: _('Invalid choice')}) | ||||
|  | ||||
|         # Re-calculate the 'pathstring' field | ||||
|         pathstring = self.construct_pathstring() | ||||
|         trees = set() | ||||
|  | ||||
|         if pathstring != self.pathstring: | ||||
|             kwargs.pop('force_insert', None) | ||||
|         if db_instance: | ||||
|             # If the tree_id or parent has changed, we need to rebuild the tree | ||||
|             if getattr(db_instance, self.NODE_PARENT_KEY) != getattr( | ||||
|                 self, self.NODE_PARENT_KEY | ||||
|             ): | ||||
|                 trees.add(db_instance.tree_id) | ||||
|             if db_instance.tree_id != self.tree_id: | ||||
|                 trees.add(self.tree_id) | ||||
|                 trees.add(db_instance.tree_id) | ||||
|         else: | ||||
|             # New instance, so we need to rebuild the tree | ||||
|             trees.add(self.tree_id) | ||||
|  | ||||
|             kwargs['force_update'] = True | ||||
|         for tree_id in trees: | ||||
|             if tree_id: | ||||
|                 self.partial_rebuild(tree_id) | ||||
|  | ||||
|             self.pathstring = pathstring | ||||
|             super().save(*args, **kwargs) | ||||
|     def partial_rebuild(self, tree_id: int) -> bool: | ||||
|         """Perform a partial rebuild of the tree structure. | ||||
|  | ||||
|             # Update the pathstring for any child nodes | ||||
|             lower_nodes = self.get_descendants(include_self=False) | ||||
|         If a failure occurs, log the error and return False. | ||||
|         """ | ||||
|         try: | ||||
|             self.__class__.objects.partial_rebuild(tree_id) | ||||
|             return True | ||||
|         except Exception as e: | ||||
|             # This is a critical error, explicitly report to sentry | ||||
|             InvenTree.sentry.report_exception(e) | ||||
|  | ||||
|             nodes_to_update = [] | ||||
|             InvenTree.exceptions.log_error(f'{self.__class__.__name__}.partial_rebuild') | ||||
|             logger.exception( | ||||
|                 'Failed to rebuild tree for %s <%s>: %s', | ||||
|                 self.__class__.__name__, | ||||
|                 self.pk, | ||||
|                 e, | ||||
|             ) | ||||
|             return False | ||||
|  | ||||
|             for node in lower_nodes: | ||||
|                 new_path = node.construct_pathstring() | ||||
|     def delete_items(self, cascade: bool = False): | ||||
|         """Delete any 'items' which exist under this node in the tree. | ||||
|  | ||||
|                 if new_path != node.pathstring: | ||||
|                     node.pathstring = new_path | ||||
|                     nodes_to_update.append(node) | ||||
|         - Note that an 'item' is an instance of a different model class. | ||||
|         - Not all tree structures will have items associated with them. | ||||
|         """ | ||||
|         if items := self.get_items(cascade=cascade): | ||||
|             items.delete() | ||||
|  | ||||
|             if len(nodes_to_update) > 0: | ||||
|                 self.__class__.objects.bulk_update(nodes_to_update, ['pathstring']) | ||||
|     def get_items(self, cascade: bool = False): | ||||
|         """Return a queryset of items which exist *under* this node in the tree. | ||||
|  | ||||
|         - For a StockLocation instance, this would be a queryset of StockItem objects | ||||
|         - For a PartCategory instance, this would be a queryset of Part objects | ||||
|  | ||||
|         The default implementation returns None, indicating that no items exist under this node. | ||||
|         """ | ||||
|         return None | ||||
|  | ||||
|     def getUniqueParents(self) -> QuerySet: | ||||
|         """Return a flat set of all parent items that exist above this node.""" | ||||
|         return self.get_ancestors() | ||||
|  | ||||
|     def getUniqueChildren(self, include_self=True) -> QuerySet: | ||||
|         """Return a flat set of all child items that exist under this node.""" | ||||
|         return self.get_descendants(include_self=include_self) | ||||
|  | ||||
|     @property | ||||
|     def has_children(self) -> bool: | ||||
|         """True if there are any children under this item.""" | ||||
|         return self.getUniqueChildren(include_self=False).count() > 0 | ||||
|  | ||||
|     @classmethod | ||||
|     def getNextTreeID(cls) -> int: | ||||
|         """Return the next available tree_id for this model class.""" | ||||
|         instance = cls.objects.order_by('-tree_id').first() | ||||
|  | ||||
|         if instance: | ||||
|             return instance.tree_id + 1 | ||||
|         else: | ||||
|             return 1 | ||||
|  | ||||
|  | ||||
| class PathStringMixin(models.Model): | ||||
|     """Mixin class for adding a 'pathstring' field to a model class. | ||||
|  | ||||
|     The pathstring is a string representation of the path to this model instance, | ||||
|     which can be used for display purposes. | ||||
|  | ||||
|     The pathstring is automatically generated when the model instance is saved. | ||||
|     """ | ||||
|  | ||||
|     # Field to use for constructing a "pathstring" for the tree | ||||
|     PATH_FIELD = 'name' | ||||
|  | ||||
|     # Extra fields to include in the get_path result. E.g. icon | ||||
|     EXTRA_PATH_FIELDS = [] | ||||
|  | ||||
|     class Meta: | ||||
|         """Metaclass options for this mixin. | ||||
|  | ||||
|         Note: abstract must be true, as this is only a mixin, not a separate table | ||||
|         """ | ||||
|  | ||||
|         abstract = True | ||||
|  | ||||
|     name = models.CharField( | ||||
|         blank=False, max_length=100, verbose_name=_('Name'), help_text=_('Name') | ||||
| @@ -769,48 +866,110 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel): | ||||
|         blank=True, max_length=250, verbose_name=_('Path'), help_text=_('Path') | ||||
|     ) | ||||
|  | ||||
|     def get_items(self, cascade=False): | ||||
|         """Return a queryset of items which exist *under* this node in the tree. | ||||
|     def save(self, *args, **kwargs): | ||||
|         """Update the pathstring field when saving the model instance.""" | ||||
|         old_pathstring = self.pathstring | ||||
|  | ||||
|         - For a StockLocation instance, this would be a queryset of StockItem objects | ||||
|         - For a PartCategory instance, this would be a queryset of Part objects | ||||
|         # Rebuild upper first, to ensure the lower nodes are updated correctly | ||||
|         super().save(*args, **kwargs) | ||||
|  | ||||
|         The default implementation returns an empty list | ||||
|         # Ensure that the pathstring is correctly constructed | ||||
|         pathstring = self.construct_pathstring(refresh=True) | ||||
|  | ||||
|         if pathstring != old_pathstring: | ||||
|             kwargs.pop('force_insert', None) | ||||
|             kwargs['force_update'] = True | ||||
|  | ||||
|             self.pathstring = pathstring | ||||
|             super().save(*args, **kwargs) | ||||
|  | ||||
|             # Bulk-update any child nodes, if applicable | ||||
|             lower_nodes = list( | ||||
|                 self.get_descendants(include_self=False).values_list('pk', flat=True) | ||||
|             ) | ||||
|  | ||||
|             self.rebuild_lower_nodes(lower_nodes) | ||||
|  | ||||
|     def delete(self, *args, **kwargs): | ||||
|         """Custom delete method for PathStringMixin. | ||||
|  | ||||
|         - Before deleting the object, update the pathstring for any child nodes. | ||||
|         - Then, delete the object. | ||||
|         """ | ||||
|         raise NotImplementedError(f'items() method not implemented for {type(self)}') | ||||
|         # Ensure that we have the latest version of the database object | ||||
|         try: | ||||
|             self.refresh_from_db() | ||||
|         except self.__class__.DoesNotExist: | ||||
|             # If the object no longer exists, raise a ValidationError | ||||
|             raise ValidationError( | ||||
|                 'Object %s of type %s no longer exists', str(self), str(self.__class__) | ||||
|             ) | ||||
|  | ||||
|     def getUniqueParents(self) -> QuerySet: | ||||
|         """Return a flat set of all parent items that exist above this node.""" | ||||
|         return self.get_ancestors() | ||||
|         # Store the node ID values for lower nodes, before we delete this one | ||||
|         lower_nodes = list( | ||||
|             self.get_descendants(include_self=False).values_list('pk', flat=True) | ||||
|         ) | ||||
|  | ||||
|     def getUniqueChildren(self, include_self=True) -> QuerySet: | ||||
|         """Return a flat set of all child items that exist under this node.""" | ||||
|         return self.get_descendants(include_self=include_self) | ||||
|         # Delete this node - after which we expect the tree structure will be updated | ||||
|         super().delete(*args, **kwargs) | ||||
|  | ||||
|     @property | ||||
|     def has_children(self) -> bool: | ||||
|         """True if there are any children under this item.""" | ||||
|         return self.getUniqueChildren(include_self=False).count() > 0 | ||||
|         # Rebuild the pathstring for lower nodes | ||||
|         self.rebuild_lower_nodes(lower_nodes) | ||||
|  | ||||
|     def getAcceptableParents(self) -> list: | ||||
|         """Returns a list of acceptable parent items within this model Acceptable parents are ones which are not underneath this item. | ||||
|     def __str__(self): | ||||
|         """String representation of a category is the full path to that category.""" | ||||
|         return f'{self.pathstring} - {self.description}' | ||||
|  | ||||
|         Setting the parent of an item to its own child results in recursion. | ||||
|     def rebuild_lower_nodes(self, lower_nodes: list[int]): | ||||
|         """Rebuild the pathstring for lower nodes in the tree. | ||||
|  | ||||
|         - This is used when the pathstring for this node is updated, and we need to update all lower nodes. | ||||
|         - We use a bulk-update to update the pathstring for all lower nodes in the tree. | ||||
|         """ | ||||
|         contents = ContentType.objects.get_for_model(type(self)) | ||||
|         nodes = self.__class__.objects.filter(pk__in=lower_nodes) | ||||
|  | ||||
|         available = contents.get_all_objects_for_this_type() | ||||
|         nodes_to_update = [] | ||||
|  | ||||
|         # List of child IDs | ||||
|         children = self.getUniqueChildren() | ||||
|         for node in nodes: | ||||
|             new_path = node.construct_pathstring() | ||||
|  | ||||
|         acceptable = [None] | ||||
|             if new_path != node.pathstring: | ||||
|                 node.pathstring = new_path | ||||
|                 nodes_to_update.append(node) | ||||
|  | ||||
|         for a in available: | ||||
|             if a.id not in children: | ||||
|                 acceptable.append(a) | ||||
|         if len(nodes_to_update) > 0: | ||||
|             self.__class__.objects.bulk_update(nodes_to_update, ['pathstring']) | ||||
|  | ||||
|         return acceptable | ||||
|     def construct_pathstring(self, refresh: bool = False) -> str: | ||||
|         """Construct the pathstring for this tree node. | ||||
|  | ||||
|         Arguments: | ||||
|             refresh: If True, force a refresh of the model instance | ||||
|         """ | ||||
|         if refresh: | ||||
|             # Refresh the model instance from the database | ||||
|             self.refresh_from_db() | ||||
|  | ||||
|         return InvenTree.helpers.constructPathString([ | ||||
|             getattr(item, self.PATH_FIELD, item.pk) for item in self.path | ||||
|         ]) | ||||
|  | ||||
|     def validate_unique(self, exclude=None): | ||||
|         """Validate that this tree instance satisfies our uniqueness requirements. | ||||
|  | ||||
|         Note that a 'unique_together' requirement for ('name', 'parent') is insufficient, | ||||
|         as it ignores cases where parent=None (i.e. top-level items) | ||||
|         """ | ||||
|         super().validate_unique(exclude) | ||||
|  | ||||
|         results = self.__class__.objects.filter( | ||||
|             name=self.name, parent=self.parent | ||||
|         ).exclude(pk=self.pk) | ||||
|  | ||||
|         if results.exists(): | ||||
|             raise ValidationError( | ||||
|                 _('Duplicate names cannot exist under the same parent') | ||||
|             ) | ||||
|  | ||||
|     @property | ||||
|     def parentpath(self) -> list: | ||||
| @@ -845,16 +1004,12 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel): | ||||
|         return [ | ||||
|             { | ||||
|                 'pk': item.pk, | ||||
|                 'name': item.name, | ||||
|                 'name': getattr(item, self.PATH_FIELD, item.pk), | ||||
|                 **{k: getattr(item, k, None) for k in self.EXTRA_PATH_FIELDS}, | ||||
|             } | ||||
|             for item in self.path | ||||
|         ] | ||||
|  | ||||
|     def __str__(self): | ||||
|         """String representation of a category is the full path to that category.""" | ||||
|         return f'{self.pathstring} - {self.description}' | ||||
|  | ||||
|  | ||||
| class InvenTreeNotesMixin(models.Model): | ||||
|     """A mixin class for adding notes functionality to a model class. | ||||
| @@ -872,7 +1027,7 @@ class InvenTreeNotesMixin(models.Model): | ||||
|  | ||||
|         abstract = True | ||||
|  | ||||
|     def delete(self): | ||||
|     def delete(self, *args, **kwargs): | ||||
|         """Custom delete method for InvenTreeNotesMixin. | ||||
|  | ||||
|         - Before deleting the object, check if there are any uploaded images associated with it. | ||||
| @@ -894,7 +1049,7 @@ class InvenTreeNotesMixin(models.Model): | ||||
|  | ||||
|             images.delete() | ||||
|  | ||||
|         super().delete() | ||||
|         super().delete(*args, **kwargs) | ||||
|  | ||||
|     notes = InvenTree.fields.InvenTreeNotesField( | ||||
|         verbose_name=_('Notes'), help_text=_('Markdown notes (optional)') | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| """Configuration for Sentry.io error reporting.""" | ||||
|  | ||||
| from typing import Optional | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.core.exceptions import ValidationError | ||||
| from django.http import Http404 | ||||
| @@ -64,17 +66,17 @@ def init_sentry(dsn, sample_rate, tags): | ||||
|     sentry_sdk.set_tag('git_date', InvenTree.version.inventreeCommitDate()) | ||||
|  | ||||
|  | ||||
| def report_exception(exc): | ||||
| def report_exception(exc, scope: Optional[dict] = None): | ||||
|     """Report an exception to sentry.io.""" | ||||
|     if settings.TESTING: | ||||
|         # Skip reporting exceptions in testing mode | ||||
|         return | ||||
|     assert settings.TESTING == False, ( | ||||
|         'report_exception should not be called in testing mode' | ||||
|     ) | ||||
|  | ||||
|     if settings.SENTRY_ENABLED and settings.SENTRY_DSN: | ||||
|         if not any(isinstance(exc, e) for e in sentry_ignore_errors()): | ||||
|             logger.info('Reporting exception to sentry.io: %s', exc) | ||||
|  | ||||
|             try: | ||||
|                 sentry_sdk.capture_exception(exc) | ||||
|                 sentry_sdk.capture_exception(exc, scope=scope) | ||||
|             except Exception: | ||||
|                 logger.warning('Failed to report exception to sentry.io') | ||||
|   | ||||
| @@ -43,6 +43,73 @@ from .tasks import offload_task | ||||
| from .validators import validate_overage | ||||
|  | ||||
|  | ||||
| class TreeFixtureTest(TestCase): | ||||
|     """Unit testing for our MPTT fixture data.""" | ||||
|  | ||||
|     fixtures = ['location', 'category', 'part', 'stock', 'build'] | ||||
|  | ||||
|     def node_string(self, node): | ||||
|         """Construct a string representation of a tree node.""" | ||||
|         return ':'.join([ | ||||
|             str(getattr(node, attr, None)) | ||||
|             for attr in ['parent', 'level', 'lft', 'rght'] | ||||
|         ]) | ||||
|  | ||||
|     def run_tree_test(self, model): | ||||
|         """Run MPTT test for a given model type. | ||||
|  | ||||
|         The intent here is to check that the MPTT tree structure | ||||
|         does not change after rebuilding the tree. | ||||
|  | ||||
|         This ensures that the fixutre data is consistent. | ||||
|         """ | ||||
|         nodes = {} | ||||
|  | ||||
|         for instance in model.objects.all(): | ||||
|             nodes[instance.pk] = self.node_string(instance) | ||||
|  | ||||
|         # Rebuild the tree structure | ||||
|         model.objects.rebuild() | ||||
|  | ||||
|         faults = [] | ||||
|  | ||||
|         # Check that no nodes have changed | ||||
|         for instance in model.objects.all().order_by('pk'): | ||||
|             ns = self.node_string(instance) | ||||
|             if ns != nodes[instance.pk]: | ||||
|                 faults.append( | ||||
|                     f'Node {instance.pk} changed: {nodes[instance.pk]} -> {ns}' | ||||
|                 ) | ||||
|  | ||||
|         if len(faults) > 0: | ||||
|             print(f'!!! Fixture data changed for: {model.__name__} !!!') | ||||
|  | ||||
|             for f in faults: | ||||
|                 print('-', f) | ||||
|  | ||||
|         assert len(faults) == 0 | ||||
|  | ||||
|     def test_part(self): | ||||
|         """Test MPTT tree structure for Part model.""" | ||||
|         from part.models import Part, PartCategory | ||||
|  | ||||
|         self.run_tree_test(Part) | ||||
|         self.run_tree_test(PartCategory) | ||||
|  | ||||
|     def test_build(self): | ||||
|         """Test MPTT tree structure for Build model.""" | ||||
|         from build.models import Build | ||||
|  | ||||
|         self.run_tree_test(Build) | ||||
|  | ||||
|     def test_stock(self): | ||||
|         """Test MPTT tree structure for Stock model.""" | ||||
|         from stock.models import StockItem, StockLocation | ||||
|  | ||||
|         self.run_tree_test(StockItem) | ||||
|         self.run_tree_test(StockLocation) | ||||
|  | ||||
|  | ||||
| class HostTest(InvenTreeTestCase): | ||||
|     """Test for host configuration.""" | ||||
|  | ||||
| @@ -811,12 +878,6 @@ class TestMPTT(TestCase): | ||||
|  | ||||
|     fixtures = ['location'] | ||||
|  | ||||
|     @classmethod | ||||
|     def setUpTestData(cls): | ||||
|         """Setup for all tests.""" | ||||
|         super().setUpTestData() | ||||
|         StockLocation.objects.rebuild() | ||||
|  | ||||
|     def test_self_as_parent(self): | ||||
|         """Test that we cannot set self as parent.""" | ||||
|         loc = StockLocation.objects.get(pk=4) | ||||
|   | ||||
| @@ -12,10 +12,10 @@ | ||||
|     status: 10  # PENDING | ||||
|     creation_date: '2019-03-16' | ||||
|     link: http://www.google.com | ||||
|     tree_id: 1 | ||||
|     level: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: build.build | ||||
|   pk: 2 | ||||
| @@ -28,10 +28,10 @@ | ||||
|     quantity: 21 | ||||
|     notes: 'Some more simple notes' | ||||
|     creation_date: '2019-03-16' | ||||
|     tree_id: 2 | ||||
|     level: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     tree_id: 1 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: build.build | ||||
|   pk: 3 | ||||
| @@ -44,10 +44,10 @@ | ||||
|     quantity: 21 | ||||
|     notes: 'Some even more simple notes' | ||||
|     creation_date: '2019-03-16' | ||||
|     tree_id: 4 | ||||
|     level: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     tree_id: 1 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: build.build | ||||
|   pk: 4 | ||||
| @@ -60,10 +60,10 @@ | ||||
|     quantity: 21 | ||||
|     notes: 'Some even even more simple notes' | ||||
|     creation_date: '2019-03-16' | ||||
|     tree_id: 5 | ||||
|     level: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     tree_id: 1 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: build.build | ||||
|   pk: 5 | ||||
| @@ -76,7 +76,7 @@ | ||||
|     quantity: 10 | ||||
|     creation_date: '2019-03-16' | ||||
|     notes: "A thing" | ||||
|     tree_id: 3 | ||||
|     level: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     tree_id: 1 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|   | ||||
| @@ -14,8 +14,7 @@ from django.urls import reverse | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
|  | ||||
| import structlog | ||||
| from mptt.exceptions import InvalidMove | ||||
| from mptt.models import MPTTModel, TreeForeignKey | ||||
| from mptt.models import TreeForeignKey | ||||
| from rest_framework import serializers | ||||
|  | ||||
| import generic.states | ||||
| @@ -74,16 +73,16 @@ class BuildReportContext(report.mixins.BaseReportContext): | ||||
|  | ||||
|  | ||||
| class Build( | ||||
|     InvenTree.models.PluginValidationMixin, | ||||
|     report.mixins.InvenTreeReportMixin, | ||||
|     InvenTree.models.InvenTreeAttachmentMixin, | ||||
|     InvenTree.models.InvenTreeBarcodeMixin, | ||||
|     InvenTree.models.InvenTreeNotesMixin, | ||||
|     InvenTree.models.MetadataMixin, | ||||
|     InvenTree.models.PluginValidationMixin, | ||||
|     InvenTree.models.ReferenceIndexingMixin, | ||||
|     StateTransitionMixin, | ||||
|     StatusCodeMixin, | ||||
|     MPTTModel, | ||||
|     InvenTree.models.MetadataMixin, | ||||
|     InvenTree.models.InvenTreeTree, | ||||
| ): | ||||
|     """A Build object organises the creation of new StockItem objects from other existing StockItem objects. | ||||
|  | ||||
| @@ -117,6 +116,11 @@ class Build( | ||||
|         verbose_name = _('Build Order') | ||||
|         verbose_name_plural = _('Build Orders') | ||||
|  | ||||
|     class MPTTMeta: | ||||
|         """MPTT options for the BuildOrder model.""" | ||||
|  | ||||
|         order_insertion_by = ['reference'] | ||||
|  | ||||
|     OVERDUE_FILTER = ( | ||||
|         Q(status__in=BuildStatusGroups.ACTIVE_CODES) | ||||
|         & ~Q(target_date=None) | ||||
| @@ -183,10 +187,7 @@ class Build( | ||||
|             if not self.destination: | ||||
|                 self.destination = self.part.get_default_location() | ||||
|  | ||||
|         try: | ||||
|             super().save(*args, **kwargs) | ||||
|         except InvalidMove: | ||||
|             raise ValidationError({'parent': _('Invalid choice for parent build')}) | ||||
|         super().save(*args, **kwargs) | ||||
|  | ||||
|     def clean(self): | ||||
|         """Validate the BuildOrder model.""" | ||||
|   | ||||
| @@ -1079,7 +1079,6 @@ class BuildListTest(BuildAPITest): | ||||
|         for ii, sub_build in enumerate(Build.objects.filter(parent=parent)): | ||||
|             for i in range(3): | ||||
|                 x = ii * 10 + i + 50 | ||||
|  | ||||
|                 Build.objects.create( | ||||
|                     part=part, | ||||
|                     reference=f'BO-{x}', | ||||
| @@ -1091,7 +1090,22 @@ class BuildListTest(BuildAPITest): | ||||
|         # 20 new builds should have been created! | ||||
|         self.assertEqual(Build.objects.count(), (n + 20)) | ||||
|  | ||||
|         Build.objects.rebuild() | ||||
|         parent.refresh_from_db() | ||||
|  | ||||
|         # There should be 5 sub-builds | ||||
|         self.assertEqual(parent.get_children().count(), 5) | ||||
|  | ||||
|         # Check tree structure for direct children | ||||
|         for sub_build in parent.get_children(): | ||||
|             self.assertEqual(sub_build.parent, parent) | ||||
|             self.assertLess(sub_build.rght, parent.rght) | ||||
|             self.assertGreater(sub_build.lft, parent.lft) | ||||
|             self.assertEqual(sub_build.level, parent.level + 1) | ||||
|             self.assertEqual(sub_build.tree_id, parent.tree_id) | ||||
|             self.assertEqual(sub_build.get_children().count(), 3) | ||||
|  | ||||
|         # And a total of 20 descendants | ||||
|         self.assertEqual(parent.get_descendants().count(), 20) | ||||
|  | ||||
|         # Search by parent | ||||
|         response = self.get(self.url, data={'parent': parent.pk}) | ||||
|   | ||||
| @@ -144,3 +144,166 @@ class BuildTestSimple(InvenTreeTestCase): | ||||
|  | ||||
|         # Check that expected quantity of new builds is created | ||||
|         self.assertEqual(Build.objects.count(), n + 4) | ||||
|  | ||||
|  | ||||
| class BuildTreeTest(InvenTreeTestCase): | ||||
|     """Unit tests for the Build tree structure.""" | ||||
|  | ||||
|     @classmethod | ||||
|     def setUpTestData(cls): | ||||
|         """Initialize test data for the Build tree tests.""" | ||||
|         from build.models import Build | ||||
|         from part.models import Part | ||||
|  | ||||
|         # Create a test assembly part | ||||
|         cls.assembly = Part.objects.create( | ||||
|             name='Test Assembly', | ||||
|             description='A test assembly part', | ||||
|             assembly=True, | ||||
|             active=True, | ||||
|             locked=False, | ||||
|         ) | ||||
|  | ||||
|         # Generate a top-level build | ||||
|         cls.build = Build.objects.create( | ||||
|             part=cls.assembly, quantity=5, reference='BO-1234', target_date=None | ||||
|         ) | ||||
|  | ||||
|     def test_basic_tree(self): | ||||
|         """Test basic tree structure functionality. | ||||
|  | ||||
|         - In this test we test a simple non-branching tree structure. | ||||
|         - Check that the tree structure is correctly created. | ||||
|         - Verify parent-child relationships and tree properties. | ||||
|         - Ensure that the number of children and descendants is as expected. | ||||
|         - Validate that the tree properties (tree_id, level, lft, rght) are correct | ||||
|         - Check that node deletion works correctly. | ||||
|         """ | ||||
|         from build.models import Build | ||||
|  | ||||
|         # Create a cascading tree structure of builds | ||||
|         child = self.build | ||||
|  | ||||
|         builds = [self.build] | ||||
|  | ||||
|         self.assertEqual(Build.objects.count(), 1) | ||||
|  | ||||
|         for i in range(10): | ||||
|             child = Build.objects.create( | ||||
|                 part=self.assembly, quantity=2, reference=f'BO-{1235 + i}', parent=child | ||||
|             ) | ||||
|  | ||||
|             builds.append(child) | ||||
|  | ||||
|         self.assertEqual(Build.objects.count(), 11) | ||||
|  | ||||
|         # Test the tree structure for each node | ||||
|         for idx, child in enumerate(builds): | ||||
|             # Check parent-child relationships | ||||
|             expected_parent = builds[idx - 1] if idx > 0 else None | ||||
|             self.assertEqual(child.parent, expected_parent) | ||||
|  | ||||
|             # Check number of children | ||||
|             expected_children = 0 if idx == 10 else 1 | ||||
|             self.assertEqual(child.get_children().count(), expected_children) | ||||
|  | ||||
|             # Check number of descendants | ||||
|             expected_descendants = max(10 - idx, 0) | ||||
|             self.assertEqual( | ||||
|                 child.get_descendants(include_self=False).count(), expected_descendants | ||||
|             ) | ||||
|  | ||||
|             # Test tree structure | ||||
|             self.assertEqual(child.tree_id, self.build.tree_id) | ||||
|             self.assertEqual(child.level, idx) | ||||
|             self.assertEqual(child.lft, idx + 1) | ||||
|             self.assertEqual(child.rght, 22 - idx) | ||||
|  | ||||
|         # Test deletion of a node - delete BO-1238 | ||||
|         Build.objects.get(reference='BO-1238').delete() | ||||
|  | ||||
|         # We expect that only a SINGLE node is deleted | ||||
|         self.assertEqual(Build.objects.count(), 10) | ||||
|         self.assertEqual(self.build.get_descendants(include_self=False).count(), 9) | ||||
|  | ||||
|         # Check that the item parents have been correctly remapped | ||||
|         build_reference_map = { | ||||
|             'BO-1235': 'BO-1234', | ||||
|             'BO-1236': 'BO-1235', | ||||
|             'BO-1237': 'BO-1236', | ||||
|             'BO-1239': 'BO-1237',  # BO-1238 was deleted, so BO-1239's parent is now BO-1237 | ||||
|             'BO-1240': 'BO-1239', | ||||
|             'BO-1241': 'BO-1240', | ||||
|             'BO-1242': 'BO-1241', | ||||
|             'BO-1243': 'BO-1242', | ||||
|             'BO-1244': 'BO-1243', | ||||
|         } | ||||
|  | ||||
|         # Check that the tree structure is still valid | ||||
|         for child_ref, parent_ref in build_reference_map.items(): | ||||
|             build = Build.objects.get(reference=child_ref) | ||||
|             parent = Build.objects.get(reference=parent_ref) | ||||
|             self.assertEqual(parent_ref, parent.reference) | ||||
|             self.assertEqual(build.tree_id, self.build.tree_id) | ||||
|             self.assertEqual(build.level, parent.level + 1) | ||||
|             self.assertEqual(build.lft, parent.lft + 1) | ||||
|             self.assertEqual(build.rght, parent.rght - 1) | ||||
|  | ||||
|     def test_complex_tree(self): | ||||
|         """Test a more complex tree structure with multiple branches. | ||||
|  | ||||
|         - Ensure that grafting nodes works correctly. | ||||
|         """ | ||||
|         ref = 1235 | ||||
|  | ||||
|         for ii in range(3): | ||||
|             # Create child builds | ||||
|             child = Build.objects.create( | ||||
|                 part=self.assembly, | ||||
|                 quantity=2, | ||||
|                 reference=f'BO-{ref + (ii * 4)}', | ||||
|                 parent=self.build, | ||||
|             ) | ||||
|  | ||||
|             for jj in range(3): | ||||
|                 # Create grandchild builds | ||||
|                 grandchild = Build.objects.create( | ||||
|                     part=self.assembly, | ||||
|                     quantity=2, | ||||
|                     reference=f'BO-{ref + (ii * 4) + jj + 1}', | ||||
|                     parent=child, | ||||
|                 ) | ||||
|  | ||||
|                 self.assertEqual(grandchild.parent, child) | ||||
|                 self.assertEqual(grandchild.tree_id, self.build.tree_id) | ||||
|                 self.assertEqual(grandchild.level, 2) | ||||
|  | ||||
|             self.assertEqual(child.get_children().count(), 3) | ||||
|             self.assertEqual(child.get_descendants(include_self=False).count(), 3) | ||||
|  | ||||
|             self.assertEqual(child.level, 1) | ||||
|             self.assertEqual(child.tree_id, self.build.tree_id) | ||||
|  | ||||
|         # Basic tests | ||||
|         self.assertEqual(Build.objects.count(), 13) | ||||
|         self.assertEqual(self.build.get_children().count(), 3) | ||||
|         self.assertEqual(self.build.get_descendants(include_self=False).count(), 12) | ||||
|  | ||||
|         # Move one of the child builds | ||||
|         build = Build.objects.get(reference='BO-1239') | ||||
|         self.assertEqual(build.parent.reference, 'BO-1234') | ||||
|         self.assertEqual(build.level, 1) | ||||
|         self.assertEqual(build.get_children().count(), 3) | ||||
|         for bo in build.get_children(): | ||||
|             self.assertEqual(bo.level, 2) | ||||
|  | ||||
|         parent = Build.objects.get(reference='BO-1235') | ||||
|         build.parent = parent | ||||
|         build.save() | ||||
|  | ||||
|         build = Build.objects.get(reference='BO-1239') | ||||
|         self.assertEqual(build.parent.reference, 'BO-1235') | ||||
|         self.assertEqual(build.level, 2) | ||||
|         self.assertEqual(build.get_children().count(), 3) | ||||
|         for bo in build.get_children(): | ||||
|             self.assertEqual(bo.level, 3) | ||||
|   | ||||
| @@ -1919,6 +1919,11 @@ class SalesOrderDownloadTest(OrderTest): | ||||
| class SalesOrderAllocateTest(OrderTest): | ||||
|     """Unit tests for allocating stock items against a SalesOrder.""" | ||||
|  | ||||
|     @classmethod | ||||
|     def setUpTestData(cls): | ||||
|         """Init routine for this unit test class.""" | ||||
|         super().setUpTestData() | ||||
|  | ||||
|     def setUp(self): | ||||
|         """Init routines for this unit testing class.""" | ||||
|         super().setUp() | ||||
| @@ -2008,7 +2013,10 @@ class SalesOrderAllocateTest(OrderTest): | ||||
|         data = {'items': [], 'shipment': self.shipment.pk} | ||||
|  | ||||
|         for line in self.order.lines.all(): | ||||
|             stock_item = line.part.stock_items.last() | ||||
|             for stock_item in line.part.stock_items.all(): | ||||
|                 # Find a non-serialized stock item to allocate | ||||
|                 if not stock_item.serialized: | ||||
|                     break | ||||
|  | ||||
|             # Fully-allocate each line | ||||
|             data['items'].append({ | ||||
| @@ -2040,11 +2048,22 @@ class SalesOrderAllocateTest(OrderTest): | ||||
|         for line in filter(check_template, self.order.lines.all()): | ||||
|             stock_item = None | ||||
|  | ||||
|             stock_item = None | ||||
|  | ||||
|             # Allocate a matching variant | ||||
|             parts = Part.objects.filter(salable=True).filter(variant_of=line.part.pk) | ||||
|             for part in parts: | ||||
|                 stock_item = part.stock_items.last() | ||||
|                 break | ||||
|  | ||||
|                 for item in part.stock_items.all(): | ||||
|                     if item.serialized: | ||||
|                         continue | ||||
|  | ||||
|                     stock_item = item | ||||
|                     break | ||||
|  | ||||
|                 if stock_item is not None: | ||||
|                     break | ||||
|  | ||||
|             # Fully-allocate each line | ||||
|             data['items'].append({ | ||||
|   | ||||
| @@ -7,8 +7,8 @@ | ||||
|     description: Electronic components | ||||
|     parent: null | ||||
|     default_location: 1 | ||||
|     level: 0 | ||||
|     tree_id: 1 | ||||
|     level: 0 | ||||
|     lft: 1 | ||||
|     rght: 12 | ||||
|  | ||||
| @@ -19,10 +19,10 @@ | ||||
|     description: Resistors | ||||
|     parent: 1 | ||||
|     default_location: null | ||||
|     level: 1 | ||||
|     tree_id: 1 | ||||
|     lft: 2 | ||||
|     rght: 3 | ||||
|     level: 1 | ||||
|     lft: 10 | ||||
|     rght: 11 | ||||
|  | ||||
| - model: part.partcategory | ||||
|   pk: 3 | ||||
| @@ -33,8 +33,8 @@ | ||||
|     default_location: null | ||||
|     level: 1 | ||||
|     tree_id: 1 | ||||
|     lft: 4 | ||||
|     rght: 5 | ||||
|     lft: 2 | ||||
|     rght: 3 | ||||
|  | ||||
| - model: part.partcategory | ||||
|   pk: 4 | ||||
| @@ -43,10 +43,10 @@ | ||||
|     description: Integrated Circuits | ||||
|     parent: 1 | ||||
|     default_location: null | ||||
|     level: 1 | ||||
|     tree_id: 1 | ||||
|     lft: 6 | ||||
|     rght: 11 | ||||
|     level: 1 | ||||
|     lft: 4 | ||||
|     rght: 9 | ||||
|  | ||||
| - model: part.partcategory | ||||
|   pk: 5 | ||||
| @@ -55,10 +55,10 @@ | ||||
|     description: Microcontrollers | ||||
|     parent: 4 | ||||
|     default_location: null | ||||
|     level: 2 | ||||
|     tree_id: 1 | ||||
|     lft: 7 | ||||
|     rght: 8 | ||||
|     level: 2 | ||||
|     lft: 5 | ||||
|     rght: 6 | ||||
|  | ||||
| - model: part.partcategory | ||||
|   pk: 6 | ||||
| @@ -67,10 +67,10 @@ | ||||
|     description: Communication interfaces | ||||
|     parent: 4 | ||||
|     default_location: null | ||||
|     level: 2 | ||||
|     tree_id: 1 | ||||
|     lft: 9 | ||||
|     rght: 10 | ||||
|     level: 2 | ||||
|     lft: 7 | ||||
|     rght: 8 | ||||
|  | ||||
| - model: part.partcategory | ||||
|   pk: 7 | ||||
| @@ -78,8 +78,8 @@ | ||||
|     name: Mechanical | ||||
|     description: Mechanical components | ||||
|     default_location: null | ||||
|     level: 0 | ||||
|     tree_id: 2 | ||||
|     level: 0 | ||||
|     lft: 1 | ||||
|     rght: 4 | ||||
|  | ||||
| @@ -90,7 +90,7 @@ | ||||
|     description: Screws, bolts, etc | ||||
|     parent: 7 | ||||
|     default_location: 5 | ||||
|     level: 1 | ||||
|     tree_id: 2 | ||||
|     level: 1 | ||||
|     lft: 2 | ||||
|     rght: 3 | ||||
|   | ||||
| @@ -8,12 +8,12 @@ | ||||
|     category: 8 | ||||
|     link: http://www.acme.com/parts/m2x4lphs | ||||
|     creation_date: '2018-01-01' | ||||
|     tree_id: 0 | ||||
|     purchaseable: True | ||||
|     testable: False | ||||
|     tree_id: 5 | ||||
|     level: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: part.part | ||||
|   pk: 2 | ||||
| @@ -22,10 +22,10 @@ | ||||
|     description: 'M3x12 socket head cap screw' | ||||
|     category: 8 | ||||
|     creation_date: '2019-02-02' | ||||
|     tree_id: 0 | ||||
|     tree_id: 6 | ||||
|     level: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| # Create some resistors | ||||
|  | ||||
| @@ -36,11 +36,10 @@ | ||||
|     description: '2.2kOhm resistor in 0805 package' | ||||
|     category: 2 | ||||
|     creation_date: '2020-03-03' | ||||
|     tree_id: 0 | ||||
|     tree_id: 8 | ||||
|     level: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|  | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: part.part | ||||
|   pk: 4 | ||||
| @@ -50,10 +49,10 @@ | ||||
|     category: 2 | ||||
|     creation_date: '2021-04-04' | ||||
|     default_location: 2  # Home/Bathroom | ||||
|     tree_id: 0 | ||||
|     tree_id: 9 | ||||
|     level: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| # Create some capacitors | ||||
| - model: part.part | ||||
| @@ -64,10 +63,10 @@ | ||||
|     purchaseable: true | ||||
|     category: 3 | ||||
|     creation_date: '2022-05-05' | ||||
|     tree_id: 0 | ||||
|     tree_id: 3 | ||||
|     level: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: part.part | ||||
|   pk: 25 | ||||
| @@ -80,11 +79,11 @@ | ||||
|     assembly: true | ||||
|     trackable: true | ||||
|     testable: true | ||||
|     tree_id: 0 | ||||
|     level: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     default_expiry: 10 | ||||
|     tree_id: 10 | ||||
|     level: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: part.part | ||||
|   pk: 50 | ||||
| @@ -94,10 +93,10 @@ | ||||
|     category: null | ||||
|     salable: true | ||||
|     creation_date: '2024-07-07' | ||||
|     tree_id: 0 | ||||
|     tree_id: 7 | ||||
|     level: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| # A part that can be made from other parts | ||||
| - model: part.part | ||||
| @@ -115,10 +114,10 @@ | ||||
|     testable: True | ||||
|     IPN: BOB | ||||
|     revision: A2 | ||||
|     tree_id: 0 | ||||
|     tree_id: 2 | ||||
|     level: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: part.part | ||||
|   pk: 101 | ||||
| @@ -128,10 +127,10 @@ | ||||
|     salable: true | ||||
|     creation_date: '2026-09-09' | ||||
|     active: True | ||||
|     tree_id: 0 | ||||
|     tree_id: 1 | ||||
|     level: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| # A 'template' part | ||||
| - model: part.part | ||||
| @@ -145,10 +144,10 @@ | ||||
|     creation_date: '2027-10-10' | ||||
|     salable: true | ||||
|     category: 7 | ||||
|     tree_id: 1 | ||||
|     tree_id: 4 | ||||
|     level: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 10 | ||||
|  | ||||
| - model: part.part | ||||
|   pk: 10001 | ||||
| @@ -160,10 +159,10 @@ | ||||
|     testable: true | ||||
|     creation_date: '2028-11-11' | ||||
|     category: 7 | ||||
|     tree_id: 1 | ||||
|     level: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     tree_id: 4 | ||||
|     level: 1 | ||||
|     lft: 2 | ||||
|     rght: 3 | ||||
|  | ||||
| - model: part.part | ||||
|   pk: 10002 | ||||
| @@ -177,10 +176,10 @@ | ||||
|     salable: true | ||||
|     creation_date: '2029-12-12' | ||||
|     category: 7 | ||||
|     tree_id: 1 | ||||
|     level: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     tree_id: 4 | ||||
|     level: 1 | ||||
|     lft: 8 | ||||
|     rght: 9 | ||||
|  | ||||
| - model: part.part | ||||
|   pk: 10003 | ||||
| @@ -193,10 +192,10 @@ | ||||
|     trackable: false | ||||
|     testable: true | ||||
|     creation_date: '2030-01-01' | ||||
|     tree_id: 1 | ||||
|     level: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     tree_id: 4 | ||||
|     level: 1 | ||||
|     lft: 4 | ||||
|     rght: 7 | ||||
|  | ||||
| - model: part.part | ||||
|   pk: 10004 | ||||
| @@ -209,10 +208,10 @@ | ||||
|     creation_date: '2031-02-02' | ||||
|     trackable: true | ||||
|     testable: true | ||||
|     tree_id: 1 | ||||
|     level: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     tree_id: 4 | ||||
|     level: 2 | ||||
|     lft: 5 | ||||
|     rght: 6 | ||||
|  | ||||
| - model: part.partrelated | ||||
|   pk: 1 | ||||
|   | ||||
| @@ -30,9 +30,8 @@ from django_cleanup import cleanup | ||||
| from djmoney.contrib.exchange.exceptions import MissingRate | ||||
| from djmoney.contrib.exchange.models import convert_money | ||||
| from djmoney.money import Money | ||||
| from mptt.exceptions import InvalidMove | ||||
| from mptt.managers import TreeManager | ||||
| from mptt.models import MPTTModel, TreeForeignKey | ||||
| from mptt.models import TreeForeignKey | ||||
| from stdimage.models import StdImageField | ||||
| from taggit.managers import TaggableManager | ||||
|  | ||||
| @@ -70,7 +69,12 @@ from stock import models as StockModels | ||||
| logger = structlog.get_logger('inventree') | ||||
|  | ||||
|  | ||||
| class PartCategory(InvenTree.models.InvenTreeTree): | ||||
| class PartCategory( | ||||
|     InvenTree.models.PluginValidationMixin, | ||||
|     InvenTree.models.MetadataMixin, | ||||
|     InvenTree.models.PathStringMixin, | ||||
|     InvenTree.models.InvenTreeTree, | ||||
| ): | ||||
|     """PartCategory provides hierarchical organization of Part objects. | ||||
|  | ||||
|     Attributes: | ||||
| @@ -401,13 +405,13 @@ class PartReportContext(report.mixins.BaseReportContext): | ||||
|  | ||||
| @cleanup.ignore | ||||
| class Part( | ||||
|     InvenTree.models.PluginValidationMixin, | ||||
|     InvenTree.models.InvenTreeAttachmentMixin, | ||||
|     InvenTree.models.InvenTreeBarcodeMixin, | ||||
|     InvenTree.models.InvenTreeNotesMixin, | ||||
|     report.mixins.InvenTreeReportMixin, | ||||
|     InvenTree.models.MetadataMixin, | ||||
|     InvenTree.models.PluginValidationMixin, | ||||
|     MPTTModel, | ||||
|     InvenTree.models.InvenTreeTree, | ||||
| ): | ||||
|     """The Part object represents an abstract part, the 'concept' of an actual entity. | ||||
|  | ||||
| @@ -447,6 +451,8 @@ class Part( | ||||
|         last_stocktake: Date at which last stocktake was performed for this Part | ||||
|     """ | ||||
|  | ||||
|     NODE_PARENT_KEY = 'variant_of' | ||||
|  | ||||
|     objects = PartManager() | ||||
|  | ||||
|     tags = TaggableManager(blank=True) | ||||
| @@ -550,10 +556,7 @@ class Part( | ||||
|  | ||||
|         self.full_clean() | ||||
|  | ||||
|         try: | ||||
|             super().save(*args, **kwargs) | ||||
|         except InvalidMove: | ||||
|             raise ValidationError({'variant_of': _('Invalid choice for parent part')}) | ||||
|         super().save(*args, **kwargs) | ||||
|  | ||||
|         if _new: | ||||
|             # Only run if the check was not run previously (due to not existing in the database) | ||||
|   | ||||
| @@ -112,8 +112,8 @@ class PartCategoryAPITest(InvenTreeAPITestCase): | ||||
|         url = reverse('api-part-category-list') | ||||
|  | ||||
|         # star categories manually for tests as it is not possible with fixures | ||||
|         # because the current user is no fixure itself and throws an invalid | ||||
|         # foreign key constrain | ||||
|         # because the current user is not fixured itself and throws an invalid | ||||
|         # foreign key constraint | ||||
|         for pk in [3, 4]: | ||||
|             PartCategory.objects.get(pk=pk).set_starred(self.user, True) | ||||
|  | ||||
| @@ -537,8 +537,6 @@ class PartCategoryAPITest(InvenTreeAPITestCase): | ||||
|                 parent=loc, | ||||
|             ) | ||||
|  | ||||
|         PartCategory.objects.rebuild() | ||||
|  | ||||
|         with self.assertNumQueriesLessThan(15): | ||||
|             response = self.get(reverse('api-part-category-tree'), expected_code=200) | ||||
|  | ||||
| @@ -588,7 +586,6 @@ class PartCategoryAPITest(InvenTreeAPITestCase): | ||||
|         sub4 = PartCategory.objects.create(name='sub4', parent=sub3) | ||||
|         sub5 = PartCategory.objects.create(name='sub5', parent=sub2) | ||||
|         Part.objects.create(name='test', category=sub4) | ||||
|         PartCategory.objects.rebuild() | ||||
|  | ||||
|         # This query will trigger an internal server error if annotation results are not limited to 1 | ||||
|         url = reverse('api-part-list') | ||||
| @@ -1057,9 +1054,6 @@ class PartAPITest(PartAPITestBase): | ||||
|  | ||||
|         Uses the 'chair template' part (pk=10000) | ||||
|         """ | ||||
|         # Rebuild the MPTT structure before running these tests | ||||
|         Part.objects.rebuild() | ||||
|  | ||||
|         url = reverse('api-part-list') | ||||
|  | ||||
|         response = self.get(url, {'variant_of': 10000}, expected_code=200) | ||||
| @@ -1105,7 +1099,6 @@ class PartAPITest(PartAPITestBase): | ||||
|     def test_variant_stock(self): | ||||
|         """Unit tests for the 'variant_stock' annotation, which provides a stock count for *variant* parts.""" | ||||
|         # Ensure the MPTT structure is in a known state before running tests | ||||
|         Part.objects.rebuild() | ||||
|  | ||||
|         # Initially, there are no "chairs" in stock, | ||||
|         # so each 'chair' template should report variant_stock=0 | ||||
| @@ -2021,9 +2014,6 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): | ||||
|         """Create test data as part of setup routine.""" | ||||
|         super().setUpTestData() | ||||
|  | ||||
|         # Ensure the part "variant" tree is correctly structured | ||||
|         Part.objects.rebuild() | ||||
|  | ||||
|         # Add a new part | ||||
|         cls.part = Part.objects.create( | ||||
|             name='Banana', | ||||
| @@ -2379,9 +2369,6 @@ class BomItemTest(InvenTreeAPITestCase): | ||||
|         """Set up the test case.""" | ||||
|         super().setUp() | ||||
|  | ||||
|         # Rebuild part tree so BOM items validate correctly | ||||
|         Part.objects.rebuild() | ||||
|  | ||||
|     def test_bom_list(self): | ||||
|         """Tests for the BomItem list endpoint.""" | ||||
|         # How many BOM items currently exist in the database? | ||||
| @@ -2569,8 +2556,6 @@ class BomItemTest(InvenTreeAPITestCase): | ||||
|  | ||||
|             variant.save() | ||||
|  | ||||
|             Part.objects.rebuild() | ||||
|  | ||||
|             # Create some stock items for this new part | ||||
|             for _ in range(ii): | ||||
|                 StockItem.objects.create(part=variant, location=loc, quantity=100) | ||||
| @@ -2700,8 +2685,6 @@ class BomItemTest(InvenTreeAPITestCase): | ||||
|  | ||||
|     def test_bom_variant_stock(self): | ||||
|         """Test for 'available_variant_stock' annotation.""" | ||||
|         Part.objects.rebuild() | ||||
|  | ||||
|         # BOM item we are interested in | ||||
|         bom_item = BomItem.objects.get(pk=1) | ||||
|  | ||||
| @@ -3080,11 +3063,6 @@ class PartMetadataAPITest(InvenTreeAPITestCase): | ||||
|  | ||||
|     roles = ['part.change', 'part_category.change'] | ||||
|  | ||||
|     def setUp(self): | ||||
|         """Setup unit tets.""" | ||||
|         super().setUp() | ||||
|         Part.objects.rebuild() | ||||
|  | ||||
|     def metatester(self, apikey, model): | ||||
|         """Generic tester.""" | ||||
|         modeldata = model.objects.first() | ||||
|   | ||||
| @@ -29,8 +29,6 @@ class BomItemTest(TestCase): | ||||
|         """Create initial data.""" | ||||
|         super().setUp() | ||||
|  | ||||
|         Part.objects.rebuild() | ||||
|  | ||||
|         self.bob = Part.objects.get(id=100) | ||||
|         self.orphan = Part.objects.get(name='Orphan') | ||||
|         self.r1 = Part.objects.get(name='R_2K2_0805') | ||||
|   | ||||
| @@ -23,8 +23,8 @@ class CategoryTest(TestCase): | ||||
|         super().setUpTestData() | ||||
|  | ||||
|         cls.electronics = PartCategory.objects.get(name='Electronics') | ||||
|         cls.mechanical = PartCategory.objects.get(name='Mechanical') | ||||
|         cls.resistors = PartCategory.objects.get(name='Resistors') | ||||
|         cls.mechanical = PartCategory.objects.get(name='Mechanical') | ||||
|         cls.capacitors = PartCategory.objects.get(name='Capacitors') | ||||
|         cls.fasteners = PartCategory.objects.get(name='Fasteners') | ||||
|         cls.ic = PartCategory.objects.get(name='IC') | ||||
| @@ -66,6 +66,7 @@ class CategoryTest(TestCase): | ||||
|     def test_path_string(self): | ||||
|         """Test that the category path string works correctly.""" | ||||
|         # Note that due to data migrations, these fields need to be saved first | ||||
|  | ||||
|         self.resistors.save() | ||||
|         self.transceivers.save() | ||||
|  | ||||
| @@ -88,6 +89,9 @@ class CategoryTest(TestCase): | ||||
|         # Move to a new parent location | ||||
|         subcat.parent = self.resistors | ||||
|         subcat.save() | ||||
|  | ||||
|         # subcat.refresh_from_db() | ||||
|  | ||||
|         self.assertEqual(subcat.pathstring, 'Electronics/Resistors/Subcategory') | ||||
|         self.assertEqual(len(subcat.path), 3) | ||||
|  | ||||
| @@ -217,6 +221,45 @@ class CategoryTest(TestCase): | ||||
|         w = Part.objects.get(name='Widget') | ||||
|         self.assertIsNone(w.get_default_location()) | ||||
|  | ||||
|     def test_root_delete(self): | ||||
|         """Test that deleting a root category works correctly.""" | ||||
|         # Clear out the existing categories | ||||
|         # Note: Cannot call bulk delete here, as it will not trigger MPTT updates | ||||
|         for p in PartCategory.objects.all(): | ||||
|             p.delete() | ||||
|  | ||||
|         # Create a new root category | ||||
|         root = PartCategory.objects.create(name='Root Category', description='Root') | ||||
|  | ||||
|         # Create a child category | ||||
|         for i in range(10): | ||||
|             PartCategory.objects.create( | ||||
|                 name=f'Child Category {i}', description='Child', parent=root | ||||
|             ) | ||||
|  | ||||
|         root.refresh_from_db() | ||||
|  | ||||
|         self.assertEqual(root.get_descendants(include_self=False).count(), 10) | ||||
|         self.assertEqual(PartCategory.objects.count(), 11) | ||||
|  | ||||
|         # There is only a single tree_id value | ||||
|         tree_ids = PartCategory.objects.values_list('tree_id', flat=True).distinct() | ||||
|         tree_ids = set(tree_ids) | ||||
|         self.assertEqual(len(tree_ids), 1) | ||||
|  | ||||
|         # Delete the root category | ||||
|         root.delete() | ||||
|  | ||||
|         # All child categories are now "root" categories | ||||
|         for cat in PartCategory.objects.all(): | ||||
|             self.assertIsNone(cat.parent) | ||||
|             self.assertEqual(cat.level, 0) | ||||
|  | ||||
|         # 10 unique tree_id values should now exist | ||||
|         tree_ids = PartCategory.objects.values_list('tree_id', flat=True).distinct() | ||||
|         tree_ids = set(tree_ids) | ||||
|         self.assertEqual(len(tree_ids), 10) | ||||
|  | ||||
|     def test_category_tree(self): | ||||
|         """Unit tests for the part category tree structure (MPTT). | ||||
|  | ||||
| @@ -226,8 +269,6 @@ class CategoryTest(TestCase): | ||||
|         # Clear out any existing parts | ||||
|         Part.objects.all().delete() | ||||
|  | ||||
|         PartCategory.objects.rebuild() | ||||
|  | ||||
|         # First, create a structured tree of part categories | ||||
|         A = PartCategory.objects.create(name='A', description='Top level category') | ||||
|  | ||||
| @@ -309,9 +350,8 @@ class CategoryTest(TestCase): | ||||
|         for loc in [B1, B2, C31, C32, C33]: | ||||
|             # These should now all be "top level" categories | ||||
|             loc.refresh_from_db() | ||||
|  | ||||
|             self.assertEqual(loc.level, 0) | ||||
|             self.assertEqual(loc.parent, None) | ||||
|             self.assertEqual(loc.level, 0) | ||||
|  | ||||
|             # Pathstring should be the same as the name | ||||
|             self.assertEqual(loc.pathstring, loc.name) | ||||
| @@ -319,6 +359,7 @@ class CategoryTest(TestCase): | ||||
|             # Test pathstring for direct children | ||||
|             for child in loc.get_children(): | ||||
|                 self.assertEqual(child.pathstring, f'{loc.name}/{child.name}') | ||||
|                 self.assertEqual(child.level, 1) | ||||
|  | ||||
|         # Check descendants for B1 | ||||
|         descendants = B1.get_descendants() | ||||
|   | ||||
| @@ -160,11 +160,8 @@ class PartTest(TestCase): | ||||
|  | ||||
|         cls.r1 = Part.objects.get(name='R_2K2_0805') | ||||
|         cls.r2 = Part.objects.get(name='R_4K7_0603') | ||||
|  | ||||
|         cls.c1 = Part.objects.get(name='C_22N_0805') | ||||
|  | ||||
|         Part.objects.rebuild() | ||||
|  | ||||
|     def test_barcode_mixin(self): | ||||
|         """Test the barcode mixin functionality.""" | ||||
|         self.assertEqual(Part.barcode_model_type(), 'part') | ||||
| @@ -173,18 +170,6 @@ class PartTest(TestCase): | ||||
|         barcode = p.format_barcode() | ||||
|         self.assertEqual(barcode, '{"part": 1}') | ||||
|  | ||||
|     def test_tree(self): | ||||
|         """Test that the part variant tree is working properly.""" | ||||
|         chair = Part.objects.get(pk=10000) | ||||
|         self.assertEqual(chair.get_children().count(), 3) | ||||
|         self.assertEqual(chair.get_descendant_count(), 4) | ||||
|  | ||||
|         green = Part.objects.get(pk=10004) | ||||
|         self.assertEqual(green.get_ancestors().count(), 2) | ||||
|         self.assertEqual(green.get_root(), chair) | ||||
|         self.assertEqual(green.get_family().count(), 3) | ||||
|         self.assertEqual(Part.objects.filter(tree_id=chair.tree_id).count(), 5) | ||||
|  | ||||
|     def test_str(self): | ||||
|         """Test string representation of a Part.""" | ||||
|         p = Part.objects.get(pk=100) | ||||
| @@ -417,7 +402,6 @@ class PartTest(TestCase): | ||||
|         ) | ||||
|  | ||||
|         with self.assertRaises(ValidationError) as exc: | ||||
|             print('rev a:', rev_a.revision_of, part.revision_of) | ||||
|             rev_a.revision_of = part | ||||
|             rev_a.save() | ||||
|  | ||||
| @@ -466,6 +450,180 @@ class PartTest(TestCase): | ||||
|         self.assertEqual(part.revisions.count(), 2) | ||||
|  | ||||
|  | ||||
| class VariantTreeTest(TestCase): | ||||
|     """Unit test for the Part variant tree structure.""" | ||||
|  | ||||
|     fixtures = ['category', 'part', 'location'] | ||||
|  | ||||
|     @classmethod | ||||
|     def setUpTestData(cls): | ||||
|         """Rebuild Part tree before running tests.""" | ||||
|         super().setUpTestData() | ||||
|  | ||||
|     def test_tree(self): | ||||
|         """Test tree structure for fixtured data.""" | ||||
|         chair = Part.objects.get(pk=10000) | ||||
|         self.assertEqual(chair.get_children().count(), 3) | ||||
|         self.assertEqual(chair.get_descendant_count(), 4) | ||||
|  | ||||
|         green = Part.objects.get(pk=10004) | ||||
|         self.assertEqual(green.get_ancestors().count(), 2) | ||||
|         self.assertEqual(green.get_root(), chair) | ||||
|         self.assertEqual(green.get_family().count(), 3) | ||||
|         self.assertEqual(Part.objects.filter(tree_id=chair.tree_id).count(), 5) | ||||
|  | ||||
|     def test_part_creation(self): | ||||
|         """Test that parts are created with the correct tree structure.""" | ||||
|         part_1 = Part.objects.create(name='Part 1', description='Part 1 description') | ||||
|  | ||||
|         part_2 = Part.objects.create(name='Part 2', description='Part 2 description') | ||||
|  | ||||
|         # Check that both parts have been created with unique tree IDs | ||||
|         self.assertNotEqual(part_1.tree_id, part_2.tree_id) | ||||
|  | ||||
|         for p in [part_1, part_2]: | ||||
|             self.assertEqual(p.level, 0) | ||||
|             self.assertEqual(p.lft, 1) | ||||
|             self.assertEqual(p.rght, 2) | ||||
|             self.assertIsNone(p.variant_of) | ||||
|  | ||||
|             self.assertEqual(Part.objects.filter(tree_id=p.tree_id).count(), 1) | ||||
|  | ||||
|     def test_complex_tree(self): | ||||
|         """Test a complex part template/variant tree.""" | ||||
|         template = Part.objects.create( | ||||
|             name='Top Level Template', | ||||
|             description='A top-level template part', | ||||
|             is_template=True, | ||||
|         ) | ||||
|  | ||||
|         # Create some variant parts | ||||
|         for x in ['A', 'B', 'C']: | ||||
|             variant = Part.objects.create( | ||||
|                 name=f'Variant {x}', | ||||
|                 description=f'Variant part {x}', | ||||
|                 variant_of=template, | ||||
|                 is_template=True, | ||||
|             ) | ||||
|  | ||||
|             for ii in range(1, 4): | ||||
|                 Part.objects.create( | ||||
|                     name=f'Sub-Variant {x}-{ii}', | ||||
|                     description=f'Sub-variant part {x}-{ii}', | ||||
|                     variant_of=variant, | ||||
|                 ) | ||||
|  | ||||
|         template.refresh_from_db() | ||||
|  | ||||
|         self.assertEqual(template.get_children().count(), 3) | ||||
|         self.assertEqual(template.get_descendants(include_self=False).count(), 12) | ||||
|  | ||||
|         for variant in template.get_children(): | ||||
|             self.assertEqual(variant.variant_of, template) | ||||
|             self.assertEqual(variant.get_ancestors().count(), 1) | ||||
|             self.assertEqual(variant.get_descendants(include_self=False).count(), 3) | ||||
|  | ||||
|             for child in variant.get_children(): | ||||
|                 self.assertEqual(child.variant_of, variant) | ||||
|                 self.assertEqual(child.get_ancestors().count(), 2) | ||||
|                 self.assertEqual(child.get_descendants(include_self=False).count(), 0) | ||||
|  | ||||
|         # Let's graft one variant onto another | ||||
|         variant_a = Part.objects.get(name='Variant A') | ||||
|         variant_b = Part.objects.get(name='Variant B') | ||||
|         variant_c = Part.objects.get(name='Variant C') | ||||
|  | ||||
|         variant_a.variant_of = variant_b | ||||
|         variant_a.save() | ||||
|  | ||||
|         template.refresh_from_db() | ||||
|         self.assertEqual(template.get_children().count(), 2) | ||||
|  | ||||
|         variant_a.refresh_from_db() | ||||
|         variant_b.refresh_from_db() | ||||
|  | ||||
|         self.assertEqual(variant_a.get_ancestors().count(), 2) | ||||
|         self.assertEqual(variant_a.variant_of, variant_b) | ||||
|         self.assertEqual(variant_b.get_children().count(), 4) | ||||
|  | ||||
|         for child in variant_a.get_children(): | ||||
|             self.assertEqual(child.variant_of, variant_a) | ||||
|             self.assertEqual(child.tree_id, template.tree_id) | ||||
|             self.assertEqual(child.get_ancestors().count(), 3) | ||||
|             self.assertEqual(child.level, 3) | ||||
|             self.assertGreater(child.lft, variant_a.lft) | ||||
|             self.assertGreater(child.lft, template.lft) | ||||
|             self.assertLess(child.rght, variant_a.rght) | ||||
|             self.assertLess(child.rght, template.rght) | ||||
|             self.assertLess(child.lft, child.rght) | ||||
|  | ||||
|         # Let's graft one variant to its own tree | ||||
|         variant_c.variant_of = None | ||||
|         variant_c.save() | ||||
|  | ||||
|         template.refresh_from_db() | ||||
|         variant_a.refresh_from_db() | ||||
|         variant_b.refresh_from_db() | ||||
|         variant_c.refresh_from_db() | ||||
|  | ||||
|         # Check total descendent count | ||||
|         self.assertEqual(template.get_descendant_count(), 8) | ||||
|         self.assertEqual(variant_a.get_descendant_count(), 3) | ||||
|         self.assertEqual(variant_b.get_descendant_count(), 7) | ||||
|         self.assertEqual(variant_c.get_descendant_count(), 3) | ||||
|  | ||||
|         # Check tree ID values | ||||
|         self.assertEqual(template.tree_id, variant_a.tree_id) | ||||
|         self.assertEqual(template.tree_id, variant_b.tree_id) | ||||
|         self.assertNotEqual(template.tree_id, variant_c.tree_id) | ||||
|  | ||||
|         for child in variant_a.get_children(): | ||||
|             # template -> variant_b -> variant_b -> child | ||||
|             self.assertEqual(child.tree_id, template.tree_id) | ||||
|             self.assertEqual(child.get_ancestors().count(), 3) | ||||
|             self.assertLess(child.lft, child.rght) | ||||
|  | ||||
|         for child in variant_b.get_children(): | ||||
|             # template -> variant_b -> child | ||||
|             self.assertEqual(child.tree_id, template.tree_id) | ||||
|             self.assertEqual(child.get_ancestors().count(), 2) | ||||
|             self.assertLess(child.lft, child.rght) | ||||
|  | ||||
|         for child in variant_c.get_children(): | ||||
|             # variant_c -> child | ||||
|             self.assertEqual(child.tree_id, variant_c.tree_id) | ||||
|             self.assertEqual(child.get_ancestors().count(), 1) | ||||
|             self.assertLess(child.lft, child.rght) | ||||
|  | ||||
|         # Next, let's delete an entire variant - ensure that sub-variants are moved up | ||||
|         b_childs = variant_b.get_children() | ||||
|  | ||||
|         with self.assertRaises(ValidationError): | ||||
|             variant_b.delete() | ||||
|  | ||||
|         # Mark as inactive to allow deletion | ||||
|         variant_b.active = False | ||||
|         variant_b.save() | ||||
|         variant_b.delete() | ||||
|  | ||||
|         template.refresh_from_db() | ||||
|         variant_a.refresh_from_db() | ||||
|  | ||||
|         # Top-level template should have now 4 direct children: | ||||
|         # - 3x children grafted from variant_a | ||||
|         # - variant_a - previously child of variant a | ||||
|         self.assertEqual(template.get_children().count(), 4) | ||||
|  | ||||
|         self.assertEqual(variant_a.get_children().count(), 3) | ||||
|         self.assertEqual(variant_a.variant_of, template) | ||||
|  | ||||
|         for child in b_childs: | ||||
|             child.refresh_from_db() | ||||
|             self.assertEqual(child.variant_of, template) | ||||
|             self.assertEqual(child.get_ancestors().count(), 1) | ||||
|             self.assertEqual(child.level, 2) | ||||
|  | ||||
|  | ||||
| class TestTemplateTest(TestCase): | ||||
|     """Unit test for the TestTemplate class.""" | ||||
|  | ||||
|   | ||||
| @@ -16,10 +16,13 @@ class SampleValidatorPluginTest(InvenTreeAPITestCase, InvenTreeTestCase): | ||||
|  | ||||
|     def setUp(self): | ||||
|         """Set up the test environment.""" | ||||
|         super().setUp() | ||||
|  | ||||
|         cat = part.models.PartCategory.objects.first() | ||||
|         self.part = part.models.Part.objects.create( | ||||
|             name='TestPart', category=cat, description='A test part', component=True | ||||
|         ) | ||||
|  | ||||
|         self.assembly = part.models.Part.objects.create( | ||||
|             name='TestAssembly', | ||||
|             category=cat, | ||||
| @@ -27,10 +30,6 @@ class SampleValidatorPluginTest(InvenTreeAPITestCase, InvenTreeTestCase): | ||||
|             component=False, | ||||
|             assembly=True, | ||||
|         ) | ||||
|         self.bom_item = part.models.BomItem.objects.create( | ||||
|             part=self.assembly, sub_part=self.part, quantity=1 | ||||
|         ) | ||||
|         super().setUp() | ||||
|  | ||||
|     def get_plugin(self): | ||||
|         """Return the SampleValidatorPlugin instance.""" | ||||
| @@ -43,6 +42,16 @@ class SampleValidatorPluginTest(InvenTreeAPITestCase, InvenTreeTestCase): | ||||
|     def test_validate_model_instance(self): | ||||
|         """Test the validate_model_instance function.""" | ||||
|         # First, ensure that the plugin is disabled | ||||
|  | ||||
|         # Create a BomItem to run tests on | ||||
|         # We need to refresh the part | ||||
|         self.part.refresh_from_db() | ||||
|         self.assembly.refresh_from_db() | ||||
|  | ||||
|         self.bom_item = part.models.BomItem.objects.create( | ||||
|             part=self.assembly, sub_part=self.part, quantity=1 | ||||
|         ) | ||||
|  | ||||
|         self.enable_plugin(False) | ||||
|  | ||||
|         plg = self.get_plugin() | ||||
|   | ||||
| @@ -27,8 +27,8 @@ | ||||
|     name: 'Dining Room' | ||||
|     description: 'A table lives here' | ||||
|     parent: 1 | ||||
|     level: 0 | ||||
|     tree_id: 1 | ||||
|     level: 1 | ||||
|     lft: 4 | ||||
|     rght: 5 | ||||
|  | ||||
| @@ -49,8 +49,8 @@ | ||||
|     name: 'Drawer_1' | ||||
|     description: 'In my desk' | ||||
|     parent: 4 | ||||
|     level: 0 | ||||
|     tree_id: 2 | ||||
|     level: 1 | ||||
|     lft: 2 | ||||
|     rght: 3 | ||||
|  | ||||
| @@ -60,8 +60,8 @@ | ||||
|     name: 'Drawer_2' | ||||
|     description: 'Also in my desk' | ||||
|     parent: 4 | ||||
|     level: 0 | ||||
|     tree_id: 2 | ||||
|     level: 1 | ||||
|     lft: 4 | ||||
|     rght: 5 | ||||
|  | ||||
| @@ -71,7 +71,7 @@ | ||||
|     name: 'Drawer_3' | ||||
|     description: 'Again, in my desk' | ||||
|     parent: 4 | ||||
|     level: 0 | ||||
|     tree_id: 2 | ||||
|     level: 1 | ||||
|     lft: 6 | ||||
|     rght: 7 | ||||
|   | ||||
| @@ -8,12 +8,12 @@ | ||||
|     location: 3 | ||||
|     batch: 'B123' | ||||
|     quantity: 4000 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     purchase_price: 123 | ||||
|     purchase_price_currency: AUD | ||||
|     tree_id: 20 | ||||
|     level: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| # 5,000 screws in the bathroom | ||||
| - model: stock.stockitem | ||||
| @@ -22,10 +22,10 @@ | ||||
|     part: 1 | ||||
|     location: 2 | ||||
|     quantity: 5000 | ||||
|     tree_id: 21 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| # Capacitor C_22N_0805 in 'Office' | ||||
| - model: stock.stockitem | ||||
| @@ -34,10 +34,10 @@ | ||||
|     part: 5 | ||||
|     location: 4 | ||||
|     quantity: 666 | ||||
|     tree_id: 16 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| # 1234 2K2 resistors in 'Drawer_1' | ||||
| - model: stock.stockitem | ||||
| @@ -46,10 +46,10 @@ | ||||
|     part: 3 | ||||
|     location: 5 | ||||
|     quantity: 1234 | ||||
|     tree_id: 22 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| # Some widgets in drawer 3 | ||||
| - model: stock.stockitem | ||||
| @@ -60,10 +60,10 @@ | ||||
|     location: 7 | ||||
|     quantity: 10 | ||||
|     delete_on_deplete: False | ||||
|     tree_id: 28 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: stock.stockitem | ||||
|   pk: 101 | ||||
| @@ -72,10 +72,10 @@ | ||||
|     batch: "B2345" | ||||
|     location: 7 | ||||
|     quantity: 5 | ||||
|     tree_id: 27 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: stock.stockitem | ||||
|   pk: 102 | ||||
| @@ -84,10 +84,10 @@ | ||||
|     batch: 'BCDE' | ||||
|     location: 7 | ||||
|     quantity: 0 | ||||
|     tree_id: 26 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: stock.stockitem | ||||
|   pk: 105 | ||||
| @@ -97,10 +97,10 @@ | ||||
|     quantity: 1 | ||||
|     serial: 1000 | ||||
|     serial_int: 1000 | ||||
|     tree_id: 29 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| # Stock items for template / variant parts | ||||
| - model: stock.stockitem | ||||
| @@ -110,10 +110,10 @@ | ||||
|     location: 7 | ||||
|     quantity: 5 | ||||
|     batch: "BBAAA" | ||||
|     tree_id: 2 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: stock.stockitem | ||||
|   pk: 501 | ||||
| @@ -123,10 +123,10 @@ | ||||
|     quantity: 1 | ||||
|     serial: 1 | ||||
|     serial_int: 1 | ||||
|     tree_id: 3 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: stock.stockitem | ||||
|   pk: 502 | ||||
| @@ -136,10 +136,10 @@ | ||||
|     quantity: 1 | ||||
|     serial: 2 | ||||
|     serial_int: 2 | ||||
|     tree_id: 4 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: stock.stockitem | ||||
|   pk: 503 | ||||
| @@ -149,10 +149,10 @@ | ||||
|     quantity: 1 | ||||
|     serial: 3 | ||||
|     serial_int: 3 | ||||
|     tree_id: 5 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: stock.stockitem | ||||
|   pk: 504 | ||||
| @@ -162,10 +162,10 @@ | ||||
|     quantity: 1 | ||||
|     serial: 4 | ||||
|     serial_int: 4 | ||||
|     tree_id: 6 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: stock.stockitem | ||||
|   pk: 505 | ||||
| @@ -175,10 +175,10 @@ | ||||
|     quantity: 1 | ||||
|     serial: 5 | ||||
|     serial_int: 5 | ||||
|     tree_id: 1 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: stock.stockitem | ||||
|   pk: 510 | ||||
| @@ -188,10 +188,10 @@ | ||||
|     quantity: 1 | ||||
|     serial: 10 | ||||
|     serial_int: 10 | ||||
|     tree_id: 24 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: stock.stockitem | ||||
|   pk: 511 | ||||
| @@ -201,10 +201,10 @@ | ||||
|     quantity: 1 | ||||
|     serial: 11 | ||||
|     serial_int: 11 | ||||
|     tree_id: 23 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: stock.stockitem | ||||
|   pk: 512 | ||||
| @@ -214,10 +214,10 @@ | ||||
|     quantity: 1 | ||||
|     serial: 12 | ||||
|     serial_int: 12 | ||||
|     tree_id: 25 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: stock.stockitem | ||||
|   pk: 520 | ||||
| @@ -227,12 +227,12 @@ | ||||
|     quantity: 1 | ||||
|     serial: 20 | ||||
|     serial_int: 20 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     expiry_date: "1990-10-10" | ||||
|     barcode_hash: 9e5ae7fc20568ed4814c10967bba8b65 | ||||
|     tree_id: 18 | ||||
|     level: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: stock.stockitem | ||||
|   pk: 521 | ||||
| @@ -242,12 +242,12 @@ | ||||
|     quantity: 1 | ||||
|     serial: 21 | ||||
|     serial_int: 21 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     status: 60 | ||||
|     barcode_hash: 1be0dfa925825c5c6c79301449e50c2d | ||||
|     tree_id: 17 | ||||
|     level: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: stock.stockitem | ||||
|   pk: 522 | ||||
| @@ -257,12 +257,12 @@ | ||||
|     quantity: 1 | ||||
|     serial: 22 | ||||
|     serial_int: 22 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     expiry_date: "1990-10-10" | ||||
|     status: 70 | ||||
|     tree_id: 19 | ||||
|     level: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| # Multiple stock items for "Bob" (PK 100) | ||||
| - model: stock.stockitem | ||||
| @@ -271,10 +271,10 @@ | ||||
|     part: 100 | ||||
|     location: 1 | ||||
|     quantity: 10 | ||||
|     tree_id: 9 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: stock.stockitem | ||||
|   pk: 1001 | ||||
| @@ -282,10 +282,10 @@ | ||||
|     part: 100 | ||||
|     location: 1 | ||||
|     quantity: 11 | ||||
|     tree_id: 14 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: stock.stockitem | ||||
|   pk: 1002 | ||||
| @@ -293,10 +293,10 @@ | ||||
|     part: 100 | ||||
|     location: 1 | ||||
|     quantity: 12 | ||||
|     tree_id: 8 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: stock.stockitem | ||||
|   pk: 1003 | ||||
| @@ -304,10 +304,10 @@ | ||||
|     part: 100 | ||||
|     location: 1 | ||||
|     quantity: 13 | ||||
|     tree_id: 15 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: stock.stockitem | ||||
|   pk: 1004 | ||||
| @@ -315,10 +315,10 @@ | ||||
|     part: 100 | ||||
|     location: 1 | ||||
|     quantity: 14 | ||||
|     tree_id: 7 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: stock.stockitem | ||||
|   pk: 1005 | ||||
| @@ -326,10 +326,10 @@ | ||||
|     part: 100 | ||||
|     location: 1 | ||||
|     quantity: 15 | ||||
|     tree_id: 13 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: stock.stockitem | ||||
|   pk: 1006 | ||||
| @@ -337,10 +337,10 @@ | ||||
|     part: 100 | ||||
|     location: 1 | ||||
|     quantity: 16 | ||||
|     tree_id: 12 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: stock.stockitem | ||||
|   pk: 1007 | ||||
| @@ -348,10 +348,10 @@ | ||||
|     part: 100 | ||||
|     location: 7 | ||||
|     quantity: 17 | ||||
|     tree_id: 11 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|  | ||||
| - model: stock.stockitem | ||||
|   pk: 1008 | ||||
| @@ -359,7 +359,7 @@ | ||||
|     part: 100 | ||||
|     location: 7 | ||||
|     quantity: 18 | ||||
|     tree_id: 10 | ||||
|     level: 0 | ||||
|     tree_id: 0 | ||||
|     lft: 0 | ||||
|     rght: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|   | ||||
| @@ -14,7 +14,7 @@ from django.core.validators import MinValueValidator | ||||
| from django.db import models, transaction | ||||
| 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.models.signals import post_delete, post_save | ||||
| from django.db.utils import IntegrityError, OperationalError | ||||
| from django.dispatch import receiver | ||||
| from django.urls import reverse | ||||
| @@ -23,7 +23,7 @@ from django.utils.translation import gettext_lazy as _ | ||||
| import structlog | ||||
| from djmoney.contrib.exchange.models import convert_money | ||||
| from mptt.managers import TreeManager | ||||
| from mptt.models import MPTTModel, TreeForeignKey | ||||
| from mptt.models import TreeForeignKey | ||||
| from taggit.managers import TaggableManager | ||||
|  | ||||
| import build.models | ||||
| @@ -135,8 +135,11 @@ class StockLocationReportContext(report.mixins.BaseReportContext): | ||||
|  | ||||
|  | ||||
| class StockLocation( | ||||
|     InvenTree.models.PluginValidationMixin, | ||||
|     InvenTree.models.InvenTreeBarcodeMixin, | ||||
|     report.mixins.InvenTreeReportMixin, | ||||
|     InvenTree.models.PathStringMixin, | ||||
|     InvenTree.models.MetadataMixin, | ||||
|     InvenTree.models.InvenTreeTree, | ||||
| ): | ||||
|     """Organization tree for StockItem objects. | ||||
| @@ -409,15 +412,15 @@ class StockItemReportContext(report.mixins.BaseReportContext): | ||||
|  | ||||
|  | ||||
| class StockItem( | ||||
|     InvenTree.models.PluginValidationMixin, | ||||
|     InvenTree.models.InvenTreeAttachmentMixin, | ||||
|     InvenTree.models.InvenTreeBarcodeMixin, | ||||
|     InvenTree.models.InvenTreeNotesMixin, | ||||
|     StatusCodeMixin, | ||||
|     report.mixins.InvenTreeReportMixin, | ||||
|     InvenTree.models.MetadataMixin, | ||||
|     InvenTree.models.PluginValidationMixin, | ||||
|     common.models.MetaMixin, | ||||
|     MPTTModel, | ||||
|     InvenTree.models.MetadataMixin, | ||||
|     InvenTree.models.InvenTreeTree, | ||||
| ): | ||||
|     """A StockItem object represents a quantity of physical instances of a part. | ||||
|  | ||||
| @@ -453,6 +456,11 @@ class StockItem( | ||||
|  | ||||
|         verbose_name = _('Stock Item') | ||||
|  | ||||
|     class MPTTMeta: | ||||
|         """MPTT metaclass options.""" | ||||
|  | ||||
|         order_insertion_by = ['part'] | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_api_url(): | ||||
|         """Return API url.""" | ||||
| @@ -600,13 +608,19 @@ class StockItem( | ||||
|             raise ValidationError({'part': _('Part must be specified')}) | ||||
|  | ||||
|         part = data['part'] | ||||
|         tree_id = kwargs.pop('tree_id', 0) | ||||
|  | ||||
|         data['parent'] = kwargs.pop('parent', None) or data.get('parent') | ||||
|         data['tree_id'] = tree_id | ||||
|         data['level'] = kwargs.pop('level', 0) | ||||
|         data['rght'] = kwargs.pop('rght', 0) | ||||
|         data['lft'] = kwargs.pop('lft', 0) | ||||
|         parent = kwargs.pop('parent', None) or data.get('parent') | ||||
|         tree_id = kwargs.pop('tree_id', StockItem.getNextTreeID()) | ||||
|  | ||||
|         if parent: | ||||
|             # Override with parent's tree_id if provided | ||||
|             tree_id = parent.tree_id | ||||
|  | ||||
|         # Pre-calculate MPTT fields | ||||
|         data['parent'] = parent if parent else None | ||||
|         data['level'] = parent.level + 1 if parent else 0 | ||||
|         data['lft'] = 0 if parent else 1 | ||||
|         data['rght'] = 0 if parent else 2 | ||||
|  | ||||
|         # Force single quantity for each item | ||||
|         data['quantity'] = 1 | ||||
| @@ -615,6 +629,13 @@ class StockItem( | ||||
|             data['serial'] = serial | ||||
|             data['serial_int'] = StockItem.convert_serial_to_int(serial) | ||||
|  | ||||
|             data['tree_id'] = tree_id | ||||
|  | ||||
|             if not parent: | ||||
|                 # No parent, this is a top-level item, so increment the tree_id | ||||
|                 # This is because each new item is a "top-level" node in the StockItem tree | ||||
|                 tree_id += 1 | ||||
|  | ||||
|             # Construct a new StockItem from the provided dict | ||||
|             items.append(StockItem(**data)) | ||||
|  | ||||
| @@ -622,9 +643,12 @@ class StockItem( | ||||
|         StockItem.objects.bulk_create(items) | ||||
|  | ||||
|         # We will need to rebuild the stock item tree manually, due to the bulk_create operation | ||||
|         InvenTree.tasks.offload_task( | ||||
|             stock.tasks.rebuild_stock_item_tree, tree_id=tree_id, group='stock' | ||||
|         ) | ||||
|         if parent and parent.tree_id: | ||||
|             # Rebuild the tree structure for this StockItem tree | ||||
|             logger.info( | ||||
|                 'Rebuilding StockItem tree structure for tree_id: %s', parent.tree_id | ||||
|             ) | ||||
|             stock.tasks.rebuild_stock_item_tree(parent.tree_id) | ||||
|  | ||||
|         # Return the newly created StockItem objects | ||||
|         return StockItem.objects.filter(part=part, serial__in=serials) | ||||
| @@ -1748,8 +1772,8 @@ class StockItem( | ||||
|         self, | ||||
|         quantity: int, | ||||
|         serials: list[str], | ||||
|         user: User, | ||||
|         notes: str = '', | ||||
|         user: Optional[User] = None, | ||||
|         notes: Optional[str] = '', | ||||
|         location: Optional[StockLocation] = None, | ||||
|     ): | ||||
|         """Split this stock item into unique serial numbers. | ||||
| @@ -2085,10 +2109,17 @@ class StockItem( | ||||
|         self.save() | ||||
|  | ||||
|         # Rebuild stock trees as required | ||||
|         rebuild_result = True | ||||
|         for tree_id in tree_ids: | ||||
|             InvenTree.tasks.offload_task( | ||||
|                 stock.tasks.rebuild_stock_item_tree, tree_id=tree_id, group='stock' | ||||
|             if not stock.tasks.rebuild_stock_item_tree(tree_id, rebuild_on_fail=False): | ||||
|                 rebuild_result = False | ||||
|  | ||||
|         if not rebuild_result: | ||||
|             # If the rebuild failed, offload the task to a background worker | ||||
|             logger.warning( | ||||
|                 'Failed to rebuild stock item tree during merge_stock_items operation, offloading task.' | ||||
|             ) | ||||
|             InvenTree.tasks.offload_task(stock.tasks.rebuild_stock_items, group='stock') | ||||
|  | ||||
|     @transaction.atomic | ||||
|     def splitStock(self, quantity, location=None, user=None, **kwargs): | ||||
| @@ -2150,7 +2181,7 @@ class StockItem( | ||||
|  | ||||
|         # Update the new stock item to ensure the tree structure is observed | ||||
|         new_stock.parent = self | ||||
|         new_stock.level = self.level + 1 | ||||
|         new_stock.tree_id = None | ||||
|  | ||||
|         # Move to the new location if specified, otherwise use current location | ||||
|         if location: | ||||
| @@ -2192,9 +2223,7 @@ class StockItem( | ||||
|         ) | ||||
|  | ||||
|         # Rebuild the tree for this parent item | ||||
|         InvenTree.tasks.offload_task( | ||||
|             stock.tasks.rebuild_stock_item_tree, tree_id=self.tree_id, group='stock' | ||||
|         ) | ||||
|         stock.tasks.rebuild_stock_item_tree(self.tree_id) | ||||
|  | ||||
|         # Attempt to reload the new item from the database | ||||
|         try: | ||||
| @@ -2648,19 +2677,6 @@ class StockItem( | ||||
|         return status['passed'] >= status['total'] | ||||
|  | ||||
|  | ||||
| @receiver(pre_delete, sender=StockItem, dispatch_uid='stock_item_pre_delete_log') | ||||
| def before_delete_stock_item(sender, instance, using, **kwargs): | ||||
|     """Receives pre_delete signal from StockItem object. | ||||
|  | ||||
|     Before a StockItem is deleted, ensure that each child object is updated, | ||||
|     to point to the new parent item. | ||||
|     """ | ||||
|     # Update each StockItem parent field | ||||
|     for child in instance.children.all(): | ||||
|         child.parent = instance.parent | ||||
|         child.save() | ||||
|  | ||||
|  | ||||
| @receiver(post_delete, sender=StockItem, dispatch_uid='stock_item_post_delete_log') | ||||
| def after_delete_stock_item(sender, instance: StockItem, **kwargs): | ||||
|     """Function to be executed after a StockItem object is deleted.""" | ||||
|   | ||||
| @@ -788,7 +788,7 @@ class SerializeStockItemSerializer(serializers.Serializer): | ||||
|             item.serializeStock( | ||||
|                 data['quantity'], | ||||
|                 serials, | ||||
|                 user, | ||||
|                 user=user, | ||||
|                 notes=data.get('notes', ''), | ||||
|                 location=data['destination'], | ||||
|             ) | ||||
|   | ||||
| @@ -7,23 +7,61 @@ tracer = trace.get_tracer(__name__) | ||||
| logger = structlog.get_logger('inventree') | ||||
|  | ||||
|  | ||||
| @tracer.start_as_current_span('rebuild_stock_item_tree') | ||||
| def rebuild_stock_item_tree(tree_id=None): | ||||
|     """Rebuild the stock tree structure. | ||||
| @tracer.start_as_current_span('rebuild_stock_items') | ||||
| def rebuild_stock_items(): | ||||
|     """Rebuild the entire StockItem tree structure. | ||||
|  | ||||
|     The StockItem tree uses the MPTT library to manage the tree structure. | ||||
|     This may be necessary if the tree structure has become corrupted or inconsistent. | ||||
|     """ | ||||
|     from InvenTree.exceptions import log_error | ||||
|     from InvenTree.sentry import report_exception | ||||
|     from stock.models import StockItem | ||||
|  | ||||
|     logger.info('Rebuilding StockItem tree structure') | ||||
|  | ||||
|     try: | ||||
|         StockItem.objects.rebuild() | ||||
|     except Exception as e: | ||||
|         # This is a critical error, explicitly report to sentry | ||||
|         report_exception(e) | ||||
|  | ||||
|         log_error('rebuild_stock_items') | ||||
|         logger.exception('Failed to rebuild StockItem tree: %s', e) | ||||
|  | ||||
|  | ||||
| def rebuild_stock_item_tree(tree_id: int, rebuild_on_fail: bool = True) -> bool: | ||||
|     """Rebuild the stock tree structure. | ||||
|  | ||||
|     Arguments: | ||||
|         tree_id (int): The ID of the StockItem tree to rebuild. | ||||
|         rebuild_on_fail (bool): If True, will attempt to rebuild the entire StockItem tree if the partial rebuild fails. | ||||
|  | ||||
|     Returns: | ||||
|         bool: True if the partial tree rebuild was successful, False otherwise. | ||||
|  | ||||
|     - If the rebuild fails, schedule a rebuild of the entire StockItem tree. | ||||
|     """ | ||||
|     from InvenTree.exceptions import log_error | ||||
|     from InvenTree.sentry import report_exception | ||||
|     from InvenTree.tasks import offload_task | ||||
|     from stock.models import StockItem | ||||
|  | ||||
|     if tree_id: | ||||
|         try: | ||||
|             StockItem.objects.partial_rebuild(tree_id) | ||||
|         except Exception: | ||||
|             logger.info('Rebuilt StockItem tree for tree_id: %s', tree_id) | ||||
|             return True | ||||
|         except Exception as e: | ||||
|             # This is a critical error, explicitly report to sentry | ||||
|             report_exception(e) | ||||
|  | ||||
|             log_error('rebuild_stock_item_tree') | ||||
|             logger.warning('Failed to rebuild StockItem tree') | ||||
|             logger.warning('Failed to rebuild StockItem tree for tree_id: %s', tree_id) | ||||
|             # If the partial rebuild fails, rebuild the entire tree | ||||
|             StockItem.objects.rebuild() | ||||
|             if rebuild_on_fail: | ||||
|                 offload_task(rebuild_stock_items, group='stock') | ||||
|             return False | ||||
|     else: | ||||
|         # No tree_id provided, so rebuild the entire tree | ||||
|         StockItem.objects.rebuild() | ||||
|         return True | ||||
|   | ||||
| @@ -73,11 +73,11 @@ class StockLocationTest(StockAPITestCase): | ||||
|             ({}, 8, 'no parameters'), | ||||
|             ({'parent': 1, 'cascade': False}, 2, 'Filter by parent, no cascading'), | ||||
|             ({'parent': 1, 'cascade': True}, 2, 'Filter by parent, cascading'), | ||||
|             ({'cascade': True, 'depth': 0}, 7, 'Cascade with no parent, depth=0'), | ||||
|             ({'cascade': True, 'depth': 0}, 3, 'Cascade with no parent, depth=0'), | ||||
|             ({'cascade': False, 'depth': 10}, 3, 'Cascade with no parent, depth=10'), | ||||
|             ( | ||||
|                 {'parent': 1, 'cascade': False, 'depth': 0}, | ||||
|                 1, | ||||
|                 0, | ||||
|                 'Dont cascade with depth=0 and parent', | ||||
|             ), | ||||
|             ( | ||||
| @@ -450,8 +450,6 @@ class StockLocationTest(StockAPITestCase): | ||||
|                 name=f'Location {idx}', description=f'Test location {idx}', parent=loc | ||||
|             ) | ||||
|  | ||||
|         StockLocation.objects.rebuild() | ||||
|  | ||||
|         with self.assertNumQueriesLessThan(15): | ||||
|             response = self.get(reverse('api-location-tree'), expected_code=200) | ||||
|  | ||||
| @@ -596,13 +594,13 @@ class StockItemListTest(StockAPITestCase): | ||||
|  | ||||
|     def test_filter_by_part(self): | ||||
|         """Filter StockItem by Part reference.""" | ||||
|         # 4 stock items associated with part 25 | ||||
|         response = self.get_stock(part=25) | ||||
|         self.assertEqual(len(response), 4) | ||||
|  | ||||
|         self.assertEqual(len(response), 17) | ||||
|  | ||||
|         # 3 stock items associated with part 10004 | ||||
|         response = self.get_stock(part=10004) | ||||
|  | ||||
|         self.assertEqual(len(response), 12) | ||||
|         self.assertEqual(len(response), 3) | ||||
|  | ||||
|     def test_filter_by_ipn(self): | ||||
|         """Filter StockItem by IPN reference.""" | ||||
| @@ -884,9 +882,15 @@ class StockItemListTest(StockAPITestCase): | ||||
|                 # Part name should match | ||||
|                 self.assertEqual(row['Part.Name'], item.part.name) | ||||
|  | ||||
|         parts = Part.objects.get(pk=25).get_descendants(include_self=True) | ||||
|         self.assertEqual(parts.count(), 1) | ||||
|  | ||||
|         items = StockItem.objects.filter(part__in=parts) | ||||
|         self.assertEqual(items.count(), 4) | ||||
|  | ||||
|         # Export stock items with a specific part | ||||
|         with self.export_data(self.list_url, {'part': 25}) as data_file: | ||||
|             self.process_csv(data_file, required_rows=17) | ||||
|             self.process_csv(data_file, required_rows=items.count()) | ||||
|  | ||||
|     def test_filter_by_allocated(self): | ||||
|         """Test that we can filter by "allocated" status. | ||||
| @@ -1034,9 +1038,9 @@ class StockItemListTest(StockAPITestCase): | ||||
|  | ||||
|         # With full data | ||||
|         response = self.post(url, {'part': 1, 'quantity': 1}) | ||||
|         self.assertEqual(response.data['serial_number'], '1001') | ||||
|         self.assertEqual(response.data['serial_number'], '1') | ||||
|         response = self.post(url, {'part': 1, 'quantity': 3}) | ||||
|         self.assertEqual(response.data['serial_number'], '1001,1002,1003') | ||||
|         self.assertEqual(response.data['serial_number'], '1,2,3') | ||||
|  | ||||
|         # Wrong quantities | ||||
|         response = self.post(url, {'part': 1, 'quantity': 'abc'}, expected_code=400) | ||||
|   | ||||
| @@ -50,89 +50,10 @@ class StockTestBase(InvenTreeTestCase): | ||||
|         cls.drawer2 = StockLocation.objects.get(name='Drawer_2') | ||||
|         cls.drawer3 = StockLocation.objects.get(name='Drawer_3') | ||||
|  | ||||
|         # Ensure the MPTT objects are correctly rebuild | ||||
|         Part.objects.rebuild() | ||||
|         StockItem.objects.rebuild() | ||||
|  | ||||
|  | ||||
| class StockTest(StockTestBase): | ||||
|     """Tests to ensure that the stock location tree functions correctly.""" | ||||
|  | ||||
|     def test_pathstring(self): | ||||
|         """Check that pathstring updates occur as expected.""" | ||||
|         a = StockLocation.objects.create(name='A') | ||||
|         b = StockLocation.objects.create(name='B', parent=a) | ||||
|         c = StockLocation.objects.create(name='C', parent=b) | ||||
|         d = StockLocation.objects.create(name='D', parent=c) | ||||
|  | ||||
|         def refresh(): | ||||
|             a.refresh_from_db() | ||||
|             b.refresh_from_db() | ||||
|             c.refresh_from_db() | ||||
|             d.refresh_from_db() | ||||
|  | ||||
|         # Initial checks | ||||
|         self.assertEqual(a.pathstring, 'A') | ||||
|         self.assertEqual(b.pathstring, 'A/B') | ||||
|         self.assertEqual(c.pathstring, 'A/B/C') | ||||
|         self.assertEqual(d.pathstring, 'A/B/C/D') | ||||
|  | ||||
|         c.name = 'Cc' | ||||
|         c.save() | ||||
|  | ||||
|         refresh() | ||||
|         self.assertEqual(a.pathstring, 'A') | ||||
|         self.assertEqual(b.pathstring, 'A/B') | ||||
|         self.assertEqual(c.pathstring, 'A/B/Cc') | ||||
|         self.assertEqual(d.pathstring, 'A/B/Cc/D') | ||||
|  | ||||
|         b.name = 'Bb' | ||||
|         b.save() | ||||
|  | ||||
|         refresh() | ||||
|         self.assertEqual(a.pathstring, 'A') | ||||
|         self.assertEqual(b.pathstring, 'A/Bb') | ||||
|         self.assertEqual(c.pathstring, 'A/Bb/Cc') | ||||
|         self.assertEqual(d.pathstring, 'A/Bb/Cc/D') | ||||
|  | ||||
|         a.name = 'Aa' | ||||
|         a.save() | ||||
|  | ||||
|         refresh() | ||||
|         self.assertEqual(a.pathstring, 'Aa') | ||||
|         self.assertEqual(b.pathstring, 'Aa/Bb') | ||||
|         self.assertEqual(c.pathstring, 'Aa/Bb/Cc') | ||||
|         self.assertEqual(d.pathstring, 'Aa/Bb/Cc/D') | ||||
|  | ||||
|         d.name = 'Dd' | ||||
|         d.save() | ||||
|  | ||||
|         refresh() | ||||
|         self.assertEqual(a.pathstring, 'Aa') | ||||
|         self.assertEqual(b.pathstring, 'Aa/Bb') | ||||
|         self.assertEqual(c.pathstring, 'Aa/Bb/Cc') | ||||
|         self.assertEqual(d.pathstring, 'Aa/Bb/Cc/Dd') | ||||
|  | ||||
|         # Test a really long name | ||||
|         # (it will be clipped to < 250 characters) | ||||
|         a.name = 'A' * 100 | ||||
|         a.save() | ||||
|         b.name = 'B' * 100 | ||||
|         b.save() | ||||
|         c.name = 'C' * 100 | ||||
|         c.save() | ||||
|         d.name = 'D' * 100 | ||||
|         d.save() | ||||
|  | ||||
|         refresh() | ||||
|         self.assertEqual(len(a.pathstring), 100) | ||||
|         self.assertEqual(len(b.pathstring), 201) | ||||
|         self.assertEqual(len(c.pathstring), 249) | ||||
|         self.assertEqual(len(d.pathstring), 249) | ||||
|  | ||||
|         self.assertTrue(d.pathstring.startswith('AAAAAAAA')) | ||||
|         self.assertTrue(d.pathstring.endswith('DDDDDDDD')) | ||||
|  | ||||
|     def test_link(self): | ||||
|         """Test the link URL field validation.""" | ||||
|         item = StockItem.objects.get(pk=1) | ||||
| @@ -768,132 +689,6 @@ class StockTest(StockTestBase): | ||||
|         # Serialize the remainder of the stock | ||||
|         item.serializeStock(2, [99, 100], self.user) | ||||
|  | ||||
|     def test_location_tree(self): | ||||
|         """Unit tests for stock location tree structure (MPTT). | ||||
|  | ||||
|         Ensure that the MPTT structure is rebuilt correctly, | ||||
|         and the current ancestor tree is observed. | ||||
|  | ||||
|         Ref: https://github.com/inventree/InvenTree/issues/2636 | ||||
|         Ref: https://github.com/inventree/InvenTree/issues/2733 | ||||
|         """ | ||||
|         # First, we will create a stock location structure | ||||
|  | ||||
|         A = StockLocation.objects.create(name='A', description='Top level location') | ||||
|         B1 = StockLocation.objects.create(name='B1', parent=A) | ||||
|         B2 = StockLocation.objects.create(name='B2', parent=A) | ||||
|         B3 = StockLocation.objects.create(name='B3', parent=A) | ||||
|         C11 = StockLocation.objects.create(name='C11', parent=B1) | ||||
|         C12 = StockLocation.objects.create(name='C12', parent=B1) | ||||
|         C21 = StockLocation.objects.create(name='C21', parent=B2) | ||||
|         C22 = StockLocation.objects.create(name='C22', parent=B2) | ||||
|         C31 = StockLocation.objects.create(name='C31', parent=B3) | ||||
|         C32 = StockLocation.objects.create(name='C32', parent=B3) | ||||
|  | ||||
|         # Check that the tree_id is correct for each sublocation | ||||
|         for loc in [B1, B2, B3, C11, C12, C21, C22, C31, C32]: | ||||
|             self.assertEqual(loc.tree_id, A.tree_id) | ||||
|  | ||||
|         # Check that the tree levels are correct for each node in the tree | ||||
|  | ||||
|         self.assertEqual(A.level, 0) | ||||
|         self.assertEqual(A.get_ancestors().count(), 0) | ||||
|  | ||||
|         for loc in [B1, B2, B3]: | ||||
|             self.assertEqual(loc.parent, A) | ||||
|             self.assertEqual(loc.level, 1) | ||||
|             self.assertEqual(loc.get_ancestors().count(), 1) | ||||
|  | ||||
|         for loc in [C11, C12]: | ||||
|             self.assertEqual(loc.parent, B1) | ||||
|             self.assertEqual(loc.level, 2) | ||||
|             self.assertEqual(loc.get_ancestors().count(), 2) | ||||
|  | ||||
|         for loc in [C21, C22]: | ||||
|             self.assertEqual(loc.parent, B2) | ||||
|             self.assertEqual(loc.level, 2) | ||||
|             self.assertEqual(loc.get_ancestors().count(), 2) | ||||
|  | ||||
|         for loc in [C31, C32]: | ||||
|             self.assertEqual(loc.parent, B3) | ||||
|             self.assertEqual(loc.level, 2) | ||||
|             self.assertEqual(loc.get_ancestors().count(), 2) | ||||
|  | ||||
|         # Spot-check for C32 | ||||
|         ancestors = C32.get_ancestors(include_self=True) | ||||
|  | ||||
|         self.assertEqual(ancestors[0], A) | ||||
|         self.assertEqual(ancestors[1], B3) | ||||
|         self.assertEqual(ancestors[2], C32) | ||||
|  | ||||
|         # At this point, we are confident that the tree is correctly structured. | ||||
|  | ||||
|         # Let's delete node B3 from the tree. We expect that: | ||||
|         # - C31 should move directly under A | ||||
|         # - C32 should move directly under A | ||||
|  | ||||
|         # Add some stock items to B3 | ||||
|         for _ in range(10): | ||||
|             StockItem.objects.create( | ||||
|                 part=Part.objects.get(pk=1), quantity=10, location=B3 | ||||
|             ) | ||||
|  | ||||
|         self.assertEqual(StockItem.objects.filter(location=B3).count(), 10) | ||||
|         self.assertEqual(StockItem.objects.filter(location=A).count(), 0) | ||||
|  | ||||
|         B3.delete() | ||||
|  | ||||
|         A.refresh_from_db() | ||||
|         C31.refresh_from_db() | ||||
|         C32.refresh_from_db() | ||||
|  | ||||
|         # Stock items have been moved to A | ||||
|         self.assertEqual(StockItem.objects.filter(location=A).count(), 10) | ||||
|  | ||||
|         # Parent should be A | ||||
|         self.assertEqual(C31.parent, A) | ||||
|         self.assertEqual(C32.parent, A) | ||||
|  | ||||
|         self.assertEqual(C31.tree_id, A.tree_id) | ||||
|         self.assertEqual(C31.level, 1) | ||||
|  | ||||
|         self.assertEqual(C32.tree_id, A.tree_id) | ||||
|         self.assertEqual(C32.level, 1) | ||||
|  | ||||
|         # Ancestor tree should be just A | ||||
|         ancestors = C31.get_ancestors() | ||||
|         self.assertEqual(ancestors.count(), 1) | ||||
|         self.assertEqual(ancestors[0], A) | ||||
|  | ||||
|         ancestors = C32.get_ancestors() | ||||
|         self.assertEqual(ancestors.count(), 1) | ||||
|         self.assertEqual(ancestors[0], A) | ||||
|  | ||||
|         # Delete A | ||||
|         A.delete() | ||||
|  | ||||
|         # Stock items have been moved to top-level location | ||||
|         self.assertEqual(StockItem.objects.filter(location=None).count(), 10) | ||||
|  | ||||
|         for loc in [B1, B2, C11, C12, C21, C22]: | ||||
|             loc.refresh_from_db() | ||||
|  | ||||
|         self.assertEqual(B1.parent, None) | ||||
|         self.assertEqual(B2.parent, None) | ||||
|  | ||||
|         self.assertEqual(C11.parent, B1) | ||||
|         self.assertEqual(C12.parent, B1) | ||||
|         self.assertEqual(C11.get_ancestors().count(), 1) | ||||
|         self.assertEqual(C12.get_ancestors().count(), 1) | ||||
|  | ||||
|         self.assertEqual(C21.parent, B2) | ||||
|         self.assertEqual(C22.parent, B2) | ||||
|  | ||||
|         ancestors = C21.get_ancestors() | ||||
|  | ||||
|         self.assertEqual(C21.get_ancestors().count(), 1) | ||||
|         self.assertEqual(C22.get_ancestors().count(), 1) | ||||
|  | ||||
|     def test_metadata(self): | ||||
|         """Unit tests for the metadata field.""" | ||||
|         for model in [StockItem, StockLocation]: | ||||
| @@ -1092,7 +887,9 @@ class VariantTest(StockTestBase): | ||||
|         item.save() | ||||
|  | ||||
|         # Attempt to create the same serial number but for a variant (should fail!) | ||||
|         # Reset the primary key and tree_id values | ||||
|         item.pk = None | ||||
|         item.tree_id = None | ||||
|         item.part = Part.objects.get(pk=10004) | ||||
|  | ||||
|         with self.assertRaises(ValidationError): | ||||
| @@ -1102,13 +899,216 @@ class VariantTest(StockTestBase): | ||||
|         item.save() | ||||
|  | ||||
|  | ||||
| class StockLocationTreeTest(StockTestBase): | ||||
|     """Unit test for the StockLocation tree structure.""" | ||||
|  | ||||
|     def test_pathstring(self): | ||||
|         """Check that pathstring updates occur as expected.""" | ||||
|         a = StockLocation.objects.create(name='A') | ||||
|         b = StockLocation.objects.create(name='B', parent=a) | ||||
|         c = StockLocation.objects.create(name='C', parent=b) | ||||
|         d = StockLocation.objects.create(name='D', parent=c) | ||||
|  | ||||
|         def refresh(): | ||||
|             a.refresh_from_db() | ||||
|             b.refresh_from_db() | ||||
|             c.refresh_from_db() | ||||
|             d.refresh_from_db() | ||||
|  | ||||
|         # Initial checks | ||||
|         self.assertEqual(a.pathstring, 'A') | ||||
|         self.assertEqual(b.pathstring, 'A/B') | ||||
|         self.assertEqual(c.pathstring, 'A/B/C') | ||||
|         self.assertEqual(d.pathstring, 'A/B/C/D') | ||||
|  | ||||
|         c.name = 'Cc' | ||||
|         c.save() | ||||
|  | ||||
|         refresh() | ||||
|         self.assertEqual(a.pathstring, 'A') | ||||
|         self.assertEqual(b.pathstring, 'A/B') | ||||
|         self.assertEqual(c.pathstring, 'A/B/Cc') | ||||
|         self.assertEqual(d.pathstring, 'A/B/Cc/D') | ||||
|  | ||||
|         b.name = 'Bb' | ||||
|         b.save() | ||||
|  | ||||
|         refresh() | ||||
|         self.assertEqual(a.pathstring, 'A') | ||||
|         self.assertEqual(b.pathstring, 'A/Bb') | ||||
|         self.assertEqual(c.pathstring, 'A/Bb/Cc') | ||||
|         self.assertEqual(d.pathstring, 'A/Bb/Cc/D') | ||||
|  | ||||
|         a.name = 'Aa' | ||||
|         a.save() | ||||
|  | ||||
|         refresh() | ||||
|         self.assertEqual(a.pathstring, 'Aa') | ||||
|         self.assertEqual(b.pathstring, 'Aa/Bb') | ||||
|         self.assertEqual(c.pathstring, 'Aa/Bb/Cc') | ||||
|         self.assertEqual(d.pathstring, 'Aa/Bb/Cc/D') | ||||
|  | ||||
|         d.name = 'Dd' | ||||
|         d.save() | ||||
|  | ||||
|         refresh() | ||||
|         self.assertEqual(a.pathstring, 'Aa') | ||||
|         self.assertEqual(b.pathstring, 'Aa/Bb') | ||||
|         self.assertEqual(c.pathstring, 'Aa/Bb/Cc') | ||||
|         self.assertEqual(d.pathstring, 'Aa/Bb/Cc/Dd') | ||||
|  | ||||
|         # Test a really long name | ||||
|         # (it will be clipped to < 250 characters) | ||||
|         a.name = 'A' * 100 | ||||
|         a.save() | ||||
|         b.name = 'B' * 100 | ||||
|         b.save() | ||||
|         c.name = 'C' * 100 | ||||
|         c.save() | ||||
|         d.name = 'D' * 100 | ||||
|         d.save() | ||||
|  | ||||
|         refresh() | ||||
|         self.assertEqual(len(a.pathstring), 100) | ||||
|         self.assertEqual(len(b.pathstring), 201) | ||||
|         self.assertEqual(len(c.pathstring), 249) | ||||
|         self.assertEqual(len(d.pathstring), 249) | ||||
|  | ||||
|         self.assertTrue(d.pathstring.startswith('AAAAAAAA')) | ||||
|         self.assertTrue(d.pathstring.endswith('DDDDDDDD')) | ||||
|  | ||||
|     def test_location_tree(self): | ||||
|         """Unit tests for stock location tree structure (MPTT). | ||||
|  | ||||
|         Ensure that the MPTT structure is rebuilt correctly, | ||||
|         and the current ancestor tree is observed. | ||||
|  | ||||
|         Ref: https://github.com/inventree/InvenTree/issues/2636 | ||||
|         Ref: https://github.com/inventree/InvenTree/issues/2733 | ||||
|         """ | ||||
|         # First, we will create a stock location structure | ||||
|  | ||||
|         A = StockLocation.objects.create(name='A', description='Top level location') | ||||
|         B1 = StockLocation.objects.create(name='B1', parent=A) | ||||
|         B2 = StockLocation.objects.create(name='B2', parent=A) | ||||
|         B3 = StockLocation.objects.create(name='B3', parent=A) | ||||
|         C11 = StockLocation.objects.create(name='C11', parent=B1) | ||||
|         C12 = StockLocation.objects.create(name='C12', parent=B1) | ||||
|         C21 = StockLocation.objects.create(name='C21', parent=B2) | ||||
|         C22 = StockLocation.objects.create(name='C22', parent=B2) | ||||
|         C31 = StockLocation.objects.create(name='C31', parent=B3) | ||||
|         C32 = StockLocation.objects.create(name='C32', parent=B3) | ||||
|  | ||||
|         # Check that the tree_id is correct for each sublocation | ||||
|         for loc in [B1, B2, B3, C11, C12, C21, C22, C31, C32]: | ||||
|             self.assertEqual(loc.tree_id, A.tree_id) | ||||
|  | ||||
|         # Check that the tree levels are correct for each node in the tree | ||||
|  | ||||
|         self.assertEqual(A.level, 0) | ||||
|         self.assertEqual(A.get_ancestors().count(), 0) | ||||
|  | ||||
|         for loc in [B1, B2, B3]: | ||||
|             self.assertEqual(loc.parent, A) | ||||
|             self.assertEqual(loc.level, 1) | ||||
|             self.assertEqual(loc.get_ancestors().count(), 1) | ||||
|  | ||||
|         for loc in [C11, C12]: | ||||
|             self.assertEqual(loc.parent, B1) | ||||
|             self.assertEqual(loc.level, 2) | ||||
|             self.assertEqual(loc.get_ancestors().count(), 2) | ||||
|  | ||||
|         for loc in [C21, C22]: | ||||
|             self.assertEqual(loc.parent, B2) | ||||
|             self.assertEqual(loc.level, 2) | ||||
|             self.assertEqual(loc.get_ancestors().count(), 2) | ||||
|  | ||||
|         for loc in [C31, C32]: | ||||
|             self.assertEqual(loc.parent, B3) | ||||
|             self.assertEqual(loc.level, 2) | ||||
|             self.assertEqual(loc.get_ancestors().count(), 2) | ||||
|  | ||||
|         # Spot-check for C32 | ||||
|         ancestors = C32.get_ancestors(include_self=True) | ||||
|  | ||||
|         self.assertEqual(ancestors[0], A) | ||||
|         self.assertEqual(ancestors[1], B3) | ||||
|         self.assertEqual(ancestors[2], C32) | ||||
|  | ||||
|         # At this point, we are confident that the tree is correctly structured. | ||||
|  | ||||
|         # Let's delete node B3 from the tree. We expect that: | ||||
|         # - C31 should move directly under A | ||||
|         # - C32 should move directly under A | ||||
|  | ||||
|         # Add some stock items to B3 | ||||
|         for _ in range(10): | ||||
|             StockItem.objects.create( | ||||
|                 part=Part.objects.get(pk=1), quantity=10, location=B3 | ||||
|             ) | ||||
|  | ||||
|         self.assertEqual(StockItem.objects.filter(location=B3).count(), 10) | ||||
|         self.assertEqual(StockItem.objects.filter(location=A).count(), 0) | ||||
|  | ||||
|         B3.delete() | ||||
|  | ||||
|         A.refresh_from_db() | ||||
|         C31.refresh_from_db() | ||||
|         C32.refresh_from_db() | ||||
|  | ||||
|         # Stock items have been moved to A | ||||
|         self.assertEqual(StockItem.objects.filter(location=A).count(), 10) | ||||
|  | ||||
|         # Parent should be A | ||||
|         self.assertEqual(C31.parent, A) | ||||
|         self.assertEqual(C32.parent, A) | ||||
|  | ||||
|         self.assertEqual(C31.tree_id, A.tree_id) | ||||
|         self.assertEqual(C31.level, 1) | ||||
|  | ||||
|         self.assertEqual(C32.tree_id, A.tree_id) | ||||
|         self.assertEqual(C32.level, 1) | ||||
|  | ||||
|         # Ancestor tree should be just A | ||||
|         ancestors = C31.get_ancestors() | ||||
|         self.assertEqual(ancestors.count(), 1) | ||||
|         self.assertEqual(ancestors[0], A) | ||||
|  | ||||
|         ancestors = C32.get_ancestors() | ||||
|         self.assertEqual(ancestors.count(), 1) | ||||
|         self.assertEqual(ancestors[0], A) | ||||
|  | ||||
|         # Delete A | ||||
|         A.delete() | ||||
|  | ||||
|         # Stock items have been moved to top-level location | ||||
|         self.assertEqual(StockItem.objects.filter(location=None).count(), 10) | ||||
|  | ||||
|         for loc in [B1, B2, C11, C12, C21, C22]: | ||||
|             loc.refresh_from_db() | ||||
|  | ||||
|         self.assertEqual(B1.parent, None) | ||||
|         self.assertEqual(B2.parent, None) | ||||
|  | ||||
|         self.assertEqual(C11.parent, B1) | ||||
|         self.assertEqual(C12.parent, B1) | ||||
|         self.assertEqual(C11.get_ancestors().count(), 1) | ||||
|         self.assertEqual(C12.get_ancestors().count(), 1) | ||||
|  | ||||
|         self.assertEqual(C21.parent, B2) | ||||
|         self.assertEqual(C22.parent, B2) | ||||
|  | ||||
|         ancestors = C21.get_ancestors() | ||||
|  | ||||
|         self.assertEqual(C21.get_ancestors().count(), 1) | ||||
|         self.assertEqual(C22.get_ancestors().count(), 1) | ||||
|  | ||||
|  | ||||
| class StockTreeTest(StockTestBase): | ||||
|     """Unit test for StockItem tree structure.""" | ||||
|  | ||||
|     def test_stock_split(self): | ||||
|         """Test that stock splitting works correctly.""" | ||||
|         StockItem.objects.rebuild() | ||||
|  | ||||
|         part = Part.objects.create(name='My part', description='My part description') | ||||
|         location = StockLocation.objects.create(name='Test Location') | ||||
|  | ||||
| @@ -1158,6 +1158,127 @@ class StockTreeTest(StockTestBase): | ||||
|         item.refresh_from_db() | ||||
|         self.assertEqual(item.get_descendants(include_self=True).count(), n + 30) | ||||
|  | ||||
|     def test_tree_rebuild(self): | ||||
|         """Test that tree rebuild works correctly.""" | ||||
|         part = Part.objects.create(name='My part', description='My part description') | ||||
|         location = StockLocation.objects.create(name='Test Location') | ||||
|  | ||||
|         N = StockItem.objects.count() | ||||
|  | ||||
|         # Create an initial stock item | ||||
|         item = StockItem.objects.create(part=part, quantity=1000, location=location) | ||||
|  | ||||
|         # Split out ten child items | ||||
|         for _idx in range(10): | ||||
|             item.splitStock(10) | ||||
|  | ||||
|         item.refresh_from_db() | ||||
|  | ||||
|         self.assertEqual(StockItem.objects.count(), N + 11) | ||||
|         self.assertEqual(item.get_children().count(), 10) | ||||
|         self.assertEqual(item.get_descendants(include_self=True).count(), 11) | ||||
|  | ||||
|         # Split the first child item | ||||
|         child = item.get_children().first() | ||||
|  | ||||
|         self.assertEqual(child.parent, item) | ||||
|         self.assertEqual(child.tree_id, item.tree_id) | ||||
|         self.assertEqual(child.level, 1) | ||||
|  | ||||
|         # Split out three grandchildren | ||||
|         for _ in range(3): | ||||
|             child.splitStock(2) | ||||
|  | ||||
|         item.refresh_from_db() | ||||
|         child.refresh_from_db() | ||||
|  | ||||
|         self.assertEqual(child.get_descendants(include_self=True).count(), 4) | ||||
|         self.assertEqual(child.get_children().count(), 3) | ||||
|  | ||||
|         # Check tree structure for grandchildren | ||||
|         grandchildren = child.get_children() | ||||
|  | ||||
|         for gc in grandchildren: | ||||
|             self.assertEqual(gc.parent, child) | ||||
|             self.assertEqual(gc.parent.parent, item) | ||||
|             self.assertEqual(gc.tree_id, item.tree_id) | ||||
|             self.assertEqual(gc.level, 2) | ||||
|             self.assertGreater(gc.lft, child.lft) | ||||
|             self.assertLess(gc.rght, child.rght) | ||||
|  | ||||
|         self.assertEqual(item.get_children().count(), 10) | ||||
|         self.assertEqual(item.get_descendants(include_self=True).count(), 14) | ||||
|  | ||||
|         # Now, delete the child node | ||||
|         # We expect that the grandchildren will be re-parented to the parent node | ||||
|         child.delete() | ||||
|  | ||||
|         for gc in grandchildren: | ||||
|             gc.refresh_from_db() | ||||
|  | ||||
|             # Check that the grandchildren have been re-parented to the top-level | ||||
|             self.assertEqual(gc.parent, item) | ||||
|             self.assertEqual(gc.tree_id, item.tree_id) | ||||
|             self.assertEqual(gc.level, 1) | ||||
|             self.assertGreater(gc.lft, item.lft) | ||||
|             self.assertLess(gc.rght, item.rght) | ||||
|  | ||||
|         item.refresh_from_db() | ||||
|  | ||||
|         self.assertEqual(item.get_children().count(), 12) | ||||
|         self.assertEqual(item.get_descendants(include_self=True).count(), 13) | ||||
|  | ||||
|     def test_serialize(self): | ||||
|         """Test that StockItem serialization maintains tree structure.""" | ||||
|         part = Part.objects.create( | ||||
|             name='My part', description='My part description', trackable=True | ||||
|         ) | ||||
|  | ||||
|         N = StockItem.objects.count() | ||||
|  | ||||
|         # Create an initial stock item | ||||
|         item_1 = StockItem.objects.create(part=part, quantity=1000) | ||||
|         item_2 = item_1.splitStock(750) | ||||
|  | ||||
|         item_1.refresh_from_db() | ||||
|  | ||||
|         self.assertEqual(StockItem.objects.count(), N + 2) | ||||
|         self.assertEqual(item_1.get_children().count(), 1) | ||||
|         self.assertEqual(item_2.parent, item_1) | ||||
|  | ||||
|         # Serialize the secondary item | ||||
|         serials = [str(i) for i in range(20)] | ||||
|         items = item_2.serializeStock(20, serials) | ||||
|  | ||||
|         self.assertEqual(len(items), 20) | ||||
|         self.assertEqual(StockItem.objects.count(), N + 22) | ||||
|  | ||||
|         item_1.refresh_from_db() | ||||
|         item_2.refresh_from_db() | ||||
|  | ||||
|         self.assertEqual(item_1.get_children().count(), 1) | ||||
|         self.assertEqual(item_2.get_children().count(), 20) | ||||
|  | ||||
|         for child in items: | ||||
|             self.assertEqual(child.tree_id, item_2.tree_id) | ||||
|             self.assertEqual(child.level, 2) | ||||
|             self.assertEqual(child.parent, item_2) | ||||
|             self.assertGreater(child.lft, item_2.lft) | ||||
|             self.assertLess(child.rght, item_2.rght) | ||||
|  | ||||
|         # Delete item_2 : we expect that all children will be re-parented to item_1 | ||||
|         item_2.delete() | ||||
|  | ||||
|         for child in items: | ||||
|             child.refresh_from_db() | ||||
|  | ||||
|             # Check that the children have been re-parented to item_1 | ||||
|             self.assertEqual(child.parent, item_1) | ||||
|             self.assertEqual(child.tree_id, item_1.tree_id) | ||||
|             self.assertEqual(child.level, 1) | ||||
|             self.assertGreater(child.lft, item_1.lft) | ||||
|             self.assertLess(child.rght, item_1.rght) | ||||
|  | ||||
|  | ||||
| class TestResultTest(StockTestBase): | ||||
|     """Tests for the StockItemTestResult model.""" | ||||
| @@ -1245,11 +1366,12 @@ class TestResultTest(StockTestBase): | ||||
|  | ||||
|         from plugin.registry import registry | ||||
|  | ||||
|         StockItem.objects.rebuild() | ||||
|  | ||||
|         item = StockItem.objects.get(pk=522) | ||||
|  | ||||
|         # Let's duplicate this item | ||||
|         item.pk = None | ||||
|         item.parent = None | ||||
|         item.tree_id = None | ||||
|         item.serial = None | ||||
|         item.quantity = 50 | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user