2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-04-26 04:53:32 +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:
Oliver
2025-07-13 21:27:32 +10:00
committed by GitHub
parent 1db6d2afbe
commit 22218fd5c6
24 changed files with 1447 additions and 666 deletions
+1 -1
View File
@@ -154,7 +154,7 @@ def generateTestKey(test_name: str) -> str:
return key 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. """Construct a 'path string' for the given path.
Arguments: Arguments:
+294 -139
View File
@@ -4,7 +4,6 @@ from datetime import datetime
from string import Formatter from string import Formatter
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import QuerySet from django.db.models import QuerySet
@@ -26,6 +25,7 @@ import InvenTree.fields
import InvenTree.format import InvenTree.format
import InvenTree.helpers import InvenTree.helpers
import InvenTree.helpers_model import InvenTree.helpers_model
import InvenTree.sentry
logger = structlog.get_logger('inventree') logger = structlog.get_logger('inventree')
@@ -123,7 +123,7 @@ class PluginValidationMixin(DiffMixin):
self.run_plugin_validation() self.run_plugin_validation()
super().save(*args, **kwargs) super().save(*args, **kwargs)
def delete(self): def delete(self, *args, **kwargs):
"""Run plugin validation on model delete. """Run plugin validation on model delete.
Allows plugins to prevent model instances from being deleted. Allows plugins to prevent model instances from being deleted.
@@ -143,7 +143,7 @@ class PluginValidationMixin(DiffMixin):
log_error('validate_model_deletion', plugin=plugin.slug) log_error('validate_model_deletion', plugin=plugin.slug)
continue continue
super().delete() super().delete(*args, **kwargs)
class MetadataMixin(models.Model): class MetadataMixin(models.Model):
@@ -474,13 +474,13 @@ class InvenTreeAttachmentMixin:
- attachments: Return a queryset containing all attachments for this model - 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. """Handle the deletion of a model instance.
Before deleting the model instance, delete any associated attachments. Before deleting the model instance, delete any associated attachments.
""" """
self.attachments.all().delete() self.attachments.all().delete()
super().delete() super().delete(*args, **kwargs)
@property @property
def attachments(self): def attachments(self):
@@ -525,44 +525,51 @@ class InvenTreeAttachmentMixin:
Attachment.objects.create(**kwargs) Attachment.objects.create(**kwargs)
class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel): class InvenTreeTree(MPTTModel):
"""Provides an abstracted self-referencing tree model for data categories. """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). Our implementation provides the following key improvements:
- Each Category can have zero-or-more child Category(y/ies)
Attributes: - Allow tracking of separate concepts of "nodes" and "items"
name: brief name - Better handling of deletion of nodes and items
description: longer form description - Ensure tree is correctly rebuilt after deletion and other operations
parent: The item immediately above this one. An item with a null parent is a top-level item - 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 # How items (not nodes) are hooked into the tree
# e.g. for StockLocation, this value is 'location' # e.g. for StockLocation, this value is 'location'
ITEM_PARENT_KEY = None ITEM_PARENT_KEY = None
# Extra fields to include in the get_path result. E.g. icon
EXTRA_PATH_FIELDS = []
class Meta: class Meta:
"""Metaclass defines extra model properties.""" """Metaclass defines extra model properties."""
abstract = True abstract = True
class MPTTMeta: class MPTTMeta:
"""Set insert order.""" """MPTT metaclass options."""
order_insertion_by = ['name'] 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. """Handle the deletion of a tree node.
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 1. Update nodes and items under the current node
2. Delete this node 2. Delete this node
3. Rebuild the model tree 3. Rebuild the model tree
4. Rebuild the path for any remaining lower nodes
""" """
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 # Ensure that we have the latest version of the database object
try: try:
@@ -573,10 +580,20 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
'Object %s of type %s no longer exists', str(self), str(self.__class__) '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 # When deleting a top level node with multiple children,
lower_nodes = list( # we need to assign a new tree_id to each child node
self.get_descendants(include_self=False).values_list('pk', flat=True) # 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 # 1. Update nodes and items under the current node
self.handle_tree_delete( self.handle_tree_delete(
@@ -584,40 +601,39 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
) )
# 2. Delete *this* node # 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: if tree_id:
try: # If this node had a tree_id, we need to rebuild that tree
self.__class__.objects.partial_rebuild(tree_id) trees.add(tree_id)
except Exception:
InvenTree.exceptions.log_error( # Did we delete a top-level node?
f'{self.__class__.__name__}.partial_rebuild' next_tree_id = self.getNextTreeID()
)
logger.warning( # If there is only one sub-tree, it can retain the same tree_id value
'Failed to rebuild tree for %s <%s>', for tree in lower_trees[1:]:
self.__class__.__name__, # Bulk update the tree_id for all lower nodes
self.pk, lower_nodes = self.__class__.objects.filter(pk__in=tree)
) lower_nodes.update(tree_id=next_tree_id)
# If the partial rebuild fails, rebuild the entire tree 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() self.__class__.objects.rebuild()
else:
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): def handle_tree_delete(self, delete_children=False, delete_items=False):
"""Delete a single instance of the tree, based on provided kwargs. """Delete a single instance of the tree, based on provided kwargs.
@@ -625,8 +641,8 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
Removing a tree "node" from the database must be considered carefully, 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. 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) - "children" are any nodes (of the same type) which exist *under* this node (e.g. PartCategory)
- "items" are any items which exist *under* this node (e.g. Part) - "items" are any items (of a different type) which exist *under* this node (e.g. Part)
Arguments: Arguments:
delete_children: If True, delete all child items 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 items at any lower level
# - Delete all descendant nodes # - Delete all descendant nodes
if delete_children and delete_items: if delete_children and delete_items:
self.get_items(cascade=True).delete() self.delete_items(cascade=True)
self.delete_nodes(child_nodes) self.delete_nodes(child_nodes)
# Case B: Delete all child nodes, but move all child items up to the parent # 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 # - Move all items at any lower level to the parent of this item
# - Delete all descendant nodes # - Delete all descendant nodes
elif delete_children and not delete_items: 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) self.delete_nodes(child_nodes)
# Case C: Delete all child items, but keep all child nodes # Case C: Delete all child items, but keep all child nodes
# - Remove all items directly associated with this node # - Remove all items directly associated with this node
# - Move any direct child nodes up one level # - Move any direct child nodes up one level
elif not delete_children and delete_items: elif not delete_children and delete_items:
self.get_items(cascade=False).delete() self.delete_items(cascade=False)
self.get_children().update(parent=self.parent) 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 # Case D: Keep all child items, and keep all child nodes
# - Move all items directly associated with this node up one level # - Move all items directly associated with this node up one level
# - Move any direct child nodes up one level # - Move any direct child nodes up one level
elif not delete_children and not delete_items: elif not delete_children and not delete_items:
self.get_items(cascade=False).update(**{self.ITEM_PARENT_KEY: self.parent}) parent = getattr(self, self.NODE_PARENT_KEY, None)
self.get_children().update(parent=self.parent) 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): def delete_nodes(self, nodes):
"""Delete a set of nodes from the tree. """Delete a set of nodes from the tree.
@@ -681,67 +701,144 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
Arguments: Arguments:
nodes: A queryset of nodes to delete nodes: A queryset of nodes to delete
""" """
nodes.update(parent=None) nodes.update(**{self.NODE_PARENT_KEY: None})
nodes.delete() 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): def api_instance_filters(self):
"""Instance filters for InvenTreeTree models.""" """Instance filters for InvenTreeTree models."""
return {'parent': {'exclude_tree': self.pk}} return {self.NODE_PARENT_KEY: {'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])
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""Custom save method for InvenTreeTree abstract model.""" """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: try:
super().save(*args, **kwargs) super().save(*args, **kwargs)
except InvalidMove: except InvalidMove:
# Provide better error for parent selection # Provide better error for parent selection
raise ValidationError({'parent': _('Invalid choice')}) raise ValidationError({self.NODE_PARENT_KEY: _('Invalid choice')})
# Re-calculate the 'pathstring' field trees = set()
pathstring = self.construct_pathstring()
if pathstring != self.pathstring: if db_instance:
kwargs.pop('force_insert', None) # 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 def partial_rebuild(self, tree_id: int) -> bool:
super().save(*args, **kwargs) """Perform a partial rebuild of the tree structure.
# Update the pathstring for any child nodes If a failure occurs, log the error and return False.
lower_nodes = self.get_descendants(include_self=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: def delete_items(self, cascade: bool = False):
new_path = node.construct_pathstring() """Delete any 'items' which exist under this node in the tree.
if new_path != node.pathstring: - Note that an 'item' is an instance of a different model class.
node.pathstring = new_path - Not all tree structures will have items associated with them.
nodes_to_update.append(node) """
if items := self.get_items(cascade=cascade):
items.delete()
if len(nodes_to_update) > 0: def get_items(self, cascade: bool = False):
self.__class__.objects.bulk_update(nodes_to_update, ['pathstring']) """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( name = models.CharField(
blank=False, max_length=100, verbose_name=_('Name'), help_text=_('Name') 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') blank=True, max_length=250, verbose_name=_('Path'), help_text=_('Path')
) )
def get_items(self, cascade=False): def save(self, *args, **kwargs):
"""Return a queryset of items which exist *under* this node in the tree. """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 # Rebuild upper first, to ensure the lower nodes are updated correctly
- For a PartCategory instance, this would be a queryset of Part objects 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: # Store the node ID values for lower nodes, before we delete this one
"""Return a flat set of all parent items that exist above this node.""" lower_nodes = list(
return self.get_ancestors() self.get_descendants(include_self=False).values_list('pk', flat=True)
)
def getUniqueChildren(self, include_self=True) -> QuerySet: # Delete this node - after which we expect the tree structure will be updated
"""Return a flat set of all child items that exist under this node.""" super().delete(*args, **kwargs)
return self.get_descendants(include_self=include_self)
@property # Rebuild the pathstring for lower nodes
def has_children(self) -> bool: self.rebuild_lower_nodes(lower_nodes)
"""True if there are any children under this item."""
return self.getUniqueChildren(include_self=False).count() > 0
def getAcceptableParents(self) -> list: def __str__(self):
"""Returns a list of acceptable parent items within this model Acceptable parents are ones which are not underneath this item. """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 for node in nodes:
children = self.getUniqueChildren() 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 len(nodes_to_update) > 0:
if a.id not in children: self.__class__.objects.bulk_update(nodes_to_update, ['pathstring'])
acceptable.append(a)
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 @property
def parentpath(self) -> list: def parentpath(self) -> list:
@@ -845,16 +1004,12 @@ class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
return [ return [
{ {
'pk': item.pk, '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}, **{k: getattr(item, k, None) for k in self.EXTRA_PATH_FIELDS},
} }
for item in self.path 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): class InvenTreeNotesMixin(models.Model):
"""A mixin class for adding notes functionality to a model class. """A mixin class for adding notes functionality to a model class.
@@ -872,7 +1027,7 @@ class InvenTreeNotesMixin(models.Model):
abstract = True abstract = True
def delete(self): def delete(self, *args, **kwargs):
"""Custom delete method for InvenTreeNotesMixin. """Custom delete method for InvenTreeNotesMixin.
- Before deleting the object, check if there are any uploaded images associated with it. - Before deleting the object, check if there are any uploaded images associated with it.
@@ -894,7 +1049,7 @@ class InvenTreeNotesMixin(models.Model):
images.delete() images.delete()
super().delete() super().delete(*args, **kwargs)
notes = InvenTree.fields.InvenTreeNotesField( notes = InvenTree.fields.InvenTreeNotesField(
verbose_name=_('Notes'), help_text=_('Markdown notes (optional)') verbose_name=_('Notes'), help_text=_('Markdown notes (optional)')
+7 -5
View File
@@ -1,5 +1,7 @@
"""Configuration for Sentry.io error reporting.""" """Configuration for Sentry.io error reporting."""
from typing import Optional
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.http import Http404 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()) 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.""" """Report an exception to sentry.io."""
if settings.TESTING: assert settings.TESTING == False, (
# Skip reporting exceptions in testing mode 'report_exception should not be called in testing mode'
return )
if settings.SENTRY_ENABLED and settings.SENTRY_DSN: if settings.SENTRY_ENABLED and settings.SENTRY_DSN:
if not any(isinstance(exc, e) for e in sentry_ignore_errors()): if not any(isinstance(exc, e) for e in sentry_ignore_errors()):
logger.info('Reporting exception to sentry.io: %s', exc) logger.info('Reporting exception to sentry.io: %s', exc)
try: try:
sentry_sdk.capture_exception(exc) sentry_sdk.capture_exception(exc, scope=scope)
except Exception: except Exception:
logger.warning('Failed to report exception to sentry.io') logger.warning('Failed to report exception to sentry.io')
+67 -6
View File
@@ -43,6 +43,73 @@ from .tasks import offload_task
from .validators import validate_overage 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): class HostTest(InvenTreeTestCase):
"""Test for host configuration.""" """Test for host configuration."""
@@ -811,12 +878,6 @@ class TestMPTT(TestCase):
fixtures = ['location'] fixtures = ['location']
@classmethod
def setUpTestData(cls):
"""Setup for all tests."""
super().setUpTestData()
StockLocation.objects.rebuild()
def test_self_as_parent(self): def test_self_as_parent(self):
"""Test that we cannot set self as parent.""" """Test that we cannot set self as parent."""
loc = StockLocation.objects.get(pk=4) loc = StockLocation.objects.get(pk=4)
+15 -15
View File
@@ -12,10 +12,10 @@
status: 10 # PENDING status: 10 # PENDING
creation_date: '2019-03-16' creation_date: '2019-03-16'
link: http://www.google.com link: http://www.google.com
tree_id: 1
level: 0 level: 0
lft: 0 lft: 1
rght: 0 rght: 2
tree_id: 0
- model: build.build - model: build.build
pk: 2 pk: 2
@@ -28,10 +28,10 @@
quantity: 21 quantity: 21
notes: 'Some more simple notes' notes: 'Some more simple notes'
creation_date: '2019-03-16' creation_date: '2019-03-16'
tree_id: 2
level: 0 level: 0
lft: 0 lft: 1
rght: 0 rght: 2
tree_id: 1
- model: build.build - model: build.build
pk: 3 pk: 3
@@ -44,10 +44,10 @@
quantity: 21 quantity: 21
notes: 'Some even more simple notes' notes: 'Some even more simple notes'
creation_date: '2019-03-16' creation_date: '2019-03-16'
tree_id: 4
level: 0 level: 0
lft: 0 lft: 1
rght: 0 rght: 2
tree_id: 1
- model: build.build - model: build.build
pk: 4 pk: 4
@@ -60,10 +60,10 @@
quantity: 21 quantity: 21
notes: 'Some even even more simple notes' notes: 'Some even even more simple notes'
creation_date: '2019-03-16' creation_date: '2019-03-16'
tree_id: 5
level: 0 level: 0
lft: 0 lft: 1
rght: 0 rght: 2
tree_id: 1
- model: build.build - model: build.build
pk: 5 pk: 5
@@ -76,7 +76,7 @@
quantity: 10 quantity: 10
creation_date: '2019-03-16' creation_date: '2019-03-16'
notes: "A thing" notes: "A thing"
tree_id: 3
level: 0 level: 0
lft: 0 lft: 1
rght: 0 rght: 2
tree_id: 1
+9 -8
View File
@@ -14,8 +14,7 @@ from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import structlog import structlog
from mptt.exceptions import InvalidMove from mptt.models import TreeForeignKey
from mptt.models import MPTTModel, TreeForeignKey
from rest_framework import serializers from rest_framework import serializers
import generic.states import generic.states
@@ -74,16 +73,16 @@ class BuildReportContext(report.mixins.BaseReportContext):
class Build( class Build(
InvenTree.models.PluginValidationMixin,
report.mixins.InvenTreeReportMixin, report.mixins.InvenTreeReportMixin,
InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeNotesMixin,
InvenTree.models.MetadataMixin,
InvenTree.models.PluginValidationMixin,
InvenTree.models.ReferenceIndexingMixin, InvenTree.models.ReferenceIndexingMixin,
StateTransitionMixin, StateTransitionMixin,
StatusCodeMixin, StatusCodeMixin,
MPTTModel, InvenTree.models.MetadataMixin,
InvenTree.models.InvenTreeTree,
): ):
"""A Build object organises the creation of new StockItem objects from other existing StockItem objects. """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 = _('Build Order')
verbose_name_plural = _('Build Orders') verbose_name_plural = _('Build Orders')
class MPTTMeta:
"""MPTT options for the BuildOrder model."""
order_insertion_by = ['reference']
OVERDUE_FILTER = ( OVERDUE_FILTER = (
Q(status__in=BuildStatusGroups.ACTIVE_CODES) Q(status__in=BuildStatusGroups.ACTIVE_CODES)
& ~Q(target_date=None) & ~Q(target_date=None)
@@ -183,10 +187,7 @@ class Build(
if not self.destination: if not self.destination:
self.destination = self.part.get_default_location() self.destination = self.part.get_default_location()
try:
super().save(*args, **kwargs) super().save(*args, **kwargs)
except InvalidMove:
raise ValidationError({'parent': _('Invalid choice for parent build')})
def clean(self): def clean(self):
"""Validate the BuildOrder model.""" """Validate the BuildOrder model."""
+16 -2
View File
@@ -1079,7 +1079,6 @@ class BuildListTest(BuildAPITest):
for ii, sub_build in enumerate(Build.objects.filter(parent=parent)): for ii, sub_build in enumerate(Build.objects.filter(parent=parent)):
for i in range(3): for i in range(3):
x = ii * 10 + i + 50 x = ii * 10 + i + 50
Build.objects.create( Build.objects.create(
part=part, part=part,
reference=f'BO-{x}', reference=f'BO-{x}',
@@ -1091,7 +1090,22 @@ class BuildListTest(BuildAPITest):
# 20 new builds should have been created! # 20 new builds should have been created!
self.assertEqual(Build.objects.count(), (n + 20)) 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 # Search by parent
response = self.get(self.url, data={'parent': parent.pk}) response = self.get(self.url, data={'parent': parent.pk})
+163
View File
@@ -144,3 +144,166 @@ class BuildTestSimple(InvenTreeTestCase):
# Check that expected quantity of new builds is created # Check that expected quantity of new builds is created
self.assertEqual(Build.objects.count(), n + 4) 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)
+20 -1
View File
@@ -1919,6 +1919,11 @@ class SalesOrderDownloadTest(OrderTest):
class SalesOrderAllocateTest(OrderTest): class SalesOrderAllocateTest(OrderTest):
"""Unit tests for allocating stock items against a SalesOrder.""" """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): def setUp(self):
"""Init routines for this unit testing class.""" """Init routines for this unit testing class."""
super().setUp() super().setUp()
@@ -2008,7 +2013,10 @@ class SalesOrderAllocateTest(OrderTest):
data = {'items': [], 'shipment': self.shipment.pk} data = {'items': [], 'shipment': self.shipment.pk}
for line in self.order.lines.all(): 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 # Fully-allocate each line
data['items'].append({ data['items'].append({
@@ -2040,10 +2048,21 @@ class SalesOrderAllocateTest(OrderTest):
for line in filter(check_template, self.order.lines.all()): for line in filter(check_template, self.order.lines.all()):
stock_item = None stock_item = None
stock_item = None
# Allocate a matching variant # Allocate a matching variant
parts = Part.objects.filter(salable=True).filter(variant_of=line.part.pk) parts = Part.objects.filter(salable=True).filter(variant_of=line.part.pk)
for part in parts: for part in parts:
stock_item = part.stock_items.last() stock_item = part.stock_items.last()
for item in part.stock_items.all():
if item.serialized:
continue
stock_item = item
break
if stock_item is not None:
break break
# Fully-allocate each line # Fully-allocate each line
@@ -7,8 +7,8 @@
description: Electronic components description: Electronic components
parent: null parent: null
default_location: 1 default_location: 1
level: 0
tree_id: 1 tree_id: 1
level: 0
lft: 1 lft: 1
rght: 12 rght: 12
@@ -19,10 +19,10 @@
description: Resistors description: Resistors
parent: 1 parent: 1
default_location: null default_location: null
level: 1
tree_id: 1 tree_id: 1
lft: 2 level: 1
rght: 3 lft: 10
rght: 11
- model: part.partcategory - model: part.partcategory
pk: 3 pk: 3
@@ -33,8 +33,8 @@
default_location: null default_location: null
level: 1 level: 1
tree_id: 1 tree_id: 1
lft: 4 lft: 2
rght: 5 rght: 3
- model: part.partcategory - model: part.partcategory
pk: 4 pk: 4
@@ -43,10 +43,10 @@
description: Integrated Circuits description: Integrated Circuits
parent: 1 parent: 1
default_location: null default_location: null
level: 1
tree_id: 1 tree_id: 1
lft: 6 level: 1
rght: 11 lft: 4
rght: 9
- model: part.partcategory - model: part.partcategory
pk: 5 pk: 5
@@ -55,10 +55,10 @@
description: Microcontrollers description: Microcontrollers
parent: 4 parent: 4
default_location: null default_location: null
level: 2
tree_id: 1 tree_id: 1
lft: 7 level: 2
rght: 8 lft: 5
rght: 6
- model: part.partcategory - model: part.partcategory
pk: 6 pk: 6
@@ -67,10 +67,10 @@
description: Communication interfaces description: Communication interfaces
parent: 4 parent: 4
default_location: null default_location: null
level: 2
tree_id: 1 tree_id: 1
lft: 9 level: 2
rght: 10 lft: 7
rght: 8
- model: part.partcategory - model: part.partcategory
pk: 7 pk: 7
@@ -78,8 +78,8 @@
name: Mechanical name: Mechanical
description: Mechanical components description: Mechanical components
default_location: null default_location: null
level: 0
tree_id: 2 tree_id: 2
level: 0
lft: 1 lft: 1
rght: 4 rght: 4
@@ -90,7 +90,7 @@
description: Screws, bolts, etc description: Screws, bolts, etc
parent: 7 parent: 7
default_location: 5 default_location: 5
level: 1
tree_id: 2 tree_id: 2
level: 1
lft: 2 lft: 2
rght: 3 rght: 3
+47 -48
View File
@@ -8,12 +8,12 @@
category: 8 category: 8
link: http://www.acme.com/parts/m2x4lphs link: http://www.acme.com/parts/m2x4lphs
creation_date: '2018-01-01' creation_date: '2018-01-01'
tree_id: 0
purchaseable: True purchaseable: True
testable: False testable: False
tree_id: 5
level: 0 level: 0
lft: 0 lft: 1
rght: 0 rght: 2
- model: part.part - model: part.part
pk: 2 pk: 2
@@ -22,10 +22,10 @@
description: 'M3x12 socket head cap screw' description: 'M3x12 socket head cap screw'
category: 8 category: 8
creation_date: '2019-02-02' creation_date: '2019-02-02'
tree_id: 0 tree_id: 6
level: 0 level: 0
lft: 0 lft: 1
rght: 0 rght: 2
# Create some resistors # Create some resistors
@@ -36,11 +36,10 @@
description: '2.2kOhm resistor in 0805 package' description: '2.2kOhm resistor in 0805 package'
category: 2 category: 2
creation_date: '2020-03-03' creation_date: '2020-03-03'
tree_id: 0 tree_id: 8
level: 0 level: 0
lft: 0 lft: 1
rght: 0 rght: 2
- model: part.part - model: part.part
pk: 4 pk: 4
@@ -50,10 +49,10 @@
category: 2 category: 2
creation_date: '2021-04-04' creation_date: '2021-04-04'
default_location: 2 # Home/Bathroom default_location: 2 # Home/Bathroom
tree_id: 0 tree_id: 9
level: 0 level: 0
lft: 0 lft: 1
rght: 0 rght: 2
# Create some capacitors # Create some capacitors
- model: part.part - model: part.part
@@ -64,10 +63,10 @@
purchaseable: true purchaseable: true
category: 3 category: 3
creation_date: '2022-05-05' creation_date: '2022-05-05'
tree_id: 0 tree_id: 3
level: 0 level: 0
lft: 0 lft: 1
rght: 0 rght: 2
- model: part.part - model: part.part
pk: 25 pk: 25
@@ -80,11 +79,11 @@
assembly: true assembly: true
trackable: true trackable: true
testable: true testable: true
tree_id: 0
level: 0
lft: 0
rght: 0
default_expiry: 10 default_expiry: 10
tree_id: 10
level: 0
lft: 1
rght: 2
- model: part.part - model: part.part
pk: 50 pk: 50
@@ -94,10 +93,10 @@
category: null category: null
salable: true salable: true
creation_date: '2024-07-07' creation_date: '2024-07-07'
tree_id: 0 tree_id: 7
level: 0 level: 0
lft: 0 lft: 1
rght: 0 rght: 2
# A part that can be made from other parts # A part that can be made from other parts
- model: part.part - model: part.part
@@ -115,10 +114,10 @@
testable: True testable: True
IPN: BOB IPN: BOB
revision: A2 revision: A2
tree_id: 0 tree_id: 2
level: 0 level: 0
lft: 0 lft: 1
rght: 0 rght: 2
- model: part.part - model: part.part
pk: 101 pk: 101
@@ -128,10 +127,10 @@
salable: true salable: true
creation_date: '2026-09-09' creation_date: '2026-09-09'
active: True active: True
tree_id: 0 tree_id: 1
level: 0 level: 0
lft: 0 lft: 1
rght: 0 rght: 2
# A 'template' part # A 'template' part
- model: part.part - model: part.part
@@ -145,10 +144,10 @@
creation_date: '2027-10-10' creation_date: '2027-10-10'
salable: true salable: true
category: 7 category: 7
tree_id: 1 tree_id: 4
level: 0 level: 0
lft: 0 lft: 1
rght: 0 rght: 10
- model: part.part - model: part.part
pk: 10001 pk: 10001
@@ -160,10 +159,10 @@
testable: true testable: true
creation_date: '2028-11-11' creation_date: '2028-11-11'
category: 7 category: 7
tree_id: 1 tree_id: 4
level: 0 level: 1
lft: 0 lft: 2
rght: 0 rght: 3
- model: part.part - model: part.part
pk: 10002 pk: 10002
@@ -177,10 +176,10 @@
salable: true salable: true
creation_date: '2029-12-12' creation_date: '2029-12-12'
category: 7 category: 7
tree_id: 1 tree_id: 4
level: 0 level: 1
lft: 0 lft: 8
rght: 0 rght: 9
- model: part.part - model: part.part
pk: 10003 pk: 10003
@@ -193,10 +192,10 @@
trackable: false trackable: false
testable: true testable: true
creation_date: '2030-01-01' creation_date: '2030-01-01'
tree_id: 1 tree_id: 4
level: 0 level: 1
lft: 0 lft: 4
rght: 0 rght: 7
- model: part.part - model: part.part
pk: 10004 pk: 10004
@@ -209,10 +208,10 @@
creation_date: '2031-02-02' creation_date: '2031-02-02'
trackable: true trackable: true
testable: true testable: true
tree_id: 1 tree_id: 4
level: 0 level: 2
lft: 0 lft: 5
rght: 0 rght: 6
- model: part.partrelated - model: part.partrelated
pk: 1 pk: 1
+11 -8
View File
@@ -30,9 +30,8 @@ from django_cleanup import cleanup
from djmoney.contrib.exchange.exceptions import MissingRate from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money from djmoney.money import Money
from mptt.exceptions import InvalidMove
from mptt.managers import TreeManager from mptt.managers import TreeManager
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import TreeForeignKey
from stdimage.models import StdImageField from stdimage.models import StdImageField
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
@@ -70,7 +69,12 @@ from stock import models as StockModels
logger = structlog.get_logger('inventree') 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. """PartCategory provides hierarchical organization of Part objects.
Attributes: Attributes:
@@ -401,13 +405,13 @@ class PartReportContext(report.mixins.BaseReportContext):
@cleanup.ignore @cleanup.ignore
class Part( class Part(
InvenTree.models.PluginValidationMixin,
InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeNotesMixin,
report.mixins.InvenTreeReportMixin, report.mixins.InvenTreeReportMixin,
InvenTree.models.MetadataMixin, InvenTree.models.MetadataMixin,
InvenTree.models.PluginValidationMixin, InvenTree.models.InvenTreeTree,
MPTTModel,
): ):
"""The Part object represents an abstract part, the 'concept' of an actual entity. """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 last_stocktake: Date at which last stocktake was performed for this Part
""" """
NODE_PARENT_KEY = 'variant_of'
objects = PartManager() objects = PartManager()
tags = TaggableManager(blank=True) tags = TaggableManager(blank=True)
@@ -550,10 +556,7 @@ class Part(
self.full_clean() self.full_clean()
try:
super().save(*args, **kwargs) super().save(*args, **kwargs)
except InvalidMove:
raise ValidationError({'variant_of': _('Invalid choice for parent part')})
if _new: if _new:
# Only run if the check was not run previously (due to not existing in the database) # Only run if the check was not run previously (due to not existing in the database)
+2 -24
View File
@@ -112,8 +112,8 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
url = reverse('api-part-category-list') url = reverse('api-part-category-list')
# star categories manually for tests as it is not possible with fixures # star categories manually for tests as it is not possible with fixures
# because the current user is no fixure itself and throws an invalid # because the current user is not fixured itself and throws an invalid
# foreign key constrain # foreign key constraint
for pk in [3, 4]: for pk in [3, 4]:
PartCategory.objects.get(pk=pk).set_starred(self.user, True) PartCategory.objects.get(pk=pk).set_starred(self.user, True)
@@ -537,8 +537,6 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
parent=loc, parent=loc,
) )
PartCategory.objects.rebuild()
with self.assertNumQueriesLessThan(15): with self.assertNumQueriesLessThan(15):
response = self.get(reverse('api-part-category-tree'), expected_code=200) 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) sub4 = PartCategory.objects.create(name='sub4', parent=sub3)
sub5 = PartCategory.objects.create(name='sub5', parent=sub2) sub5 = PartCategory.objects.create(name='sub5', parent=sub2)
Part.objects.create(name='test', category=sub4) 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 # This query will trigger an internal server error if annotation results are not limited to 1
url = reverse('api-part-list') url = reverse('api-part-list')
@@ -1057,9 +1054,6 @@ class PartAPITest(PartAPITestBase):
Uses the 'chair template' part (pk=10000) Uses the 'chair template' part (pk=10000)
""" """
# Rebuild the MPTT structure before running these tests
Part.objects.rebuild()
url = reverse('api-part-list') url = reverse('api-part-list')
response = self.get(url, {'variant_of': 10000}, expected_code=200) response = self.get(url, {'variant_of': 10000}, expected_code=200)
@@ -1105,7 +1099,6 @@ class PartAPITest(PartAPITestBase):
def test_variant_stock(self): def test_variant_stock(self):
"""Unit tests for the 'variant_stock' annotation, which provides a stock count for *variant* parts.""" """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 # Ensure the MPTT structure is in a known state before running tests
Part.objects.rebuild()
# Initially, there are no "chairs" in stock, # Initially, there are no "chairs" in stock,
# so each 'chair' template should report variant_stock=0 # so each 'chair' template should report variant_stock=0
@@ -2021,9 +2014,6 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
"""Create test data as part of setup routine.""" """Create test data as part of setup routine."""
super().setUpTestData() super().setUpTestData()
# Ensure the part "variant" tree is correctly structured
Part.objects.rebuild()
# Add a new part # Add a new part
cls.part = Part.objects.create( cls.part = Part.objects.create(
name='Banana', name='Banana',
@@ -2379,9 +2369,6 @@ class BomItemTest(InvenTreeAPITestCase):
"""Set up the test case.""" """Set up the test case."""
super().setUp() super().setUp()
# Rebuild part tree so BOM items validate correctly
Part.objects.rebuild()
def test_bom_list(self): def test_bom_list(self):
"""Tests for the BomItem list endpoint.""" """Tests for the BomItem list endpoint."""
# How many BOM items currently exist in the database? # How many BOM items currently exist in the database?
@@ -2569,8 +2556,6 @@ class BomItemTest(InvenTreeAPITestCase):
variant.save() variant.save()
Part.objects.rebuild()
# Create some stock items for this new part # Create some stock items for this new part
for _ in range(ii): for _ in range(ii):
StockItem.objects.create(part=variant, location=loc, quantity=100) StockItem.objects.create(part=variant, location=loc, quantity=100)
@@ -2700,8 +2685,6 @@ class BomItemTest(InvenTreeAPITestCase):
def test_bom_variant_stock(self): def test_bom_variant_stock(self):
"""Test for 'available_variant_stock' annotation.""" """Test for 'available_variant_stock' annotation."""
Part.objects.rebuild()
# BOM item we are interested in # BOM item we are interested in
bom_item = BomItem.objects.get(pk=1) bom_item = BomItem.objects.get(pk=1)
@@ -3080,11 +3063,6 @@ class PartMetadataAPITest(InvenTreeAPITestCase):
roles = ['part.change', 'part_category.change'] roles = ['part.change', 'part_category.change']
def setUp(self):
"""Setup unit tets."""
super().setUp()
Part.objects.rebuild()
def metatester(self, apikey, model): def metatester(self, apikey, model):
"""Generic tester.""" """Generic tester."""
modeldata = model.objects.first() modeldata = model.objects.first()
@@ -29,8 +29,6 @@ class BomItemTest(TestCase):
"""Create initial data.""" """Create initial data."""
super().setUp() super().setUp()
Part.objects.rebuild()
self.bob = Part.objects.get(id=100) self.bob = Part.objects.get(id=100)
self.orphan = Part.objects.get(name='Orphan') self.orphan = Part.objects.get(name='Orphan')
self.r1 = Part.objects.get(name='R_2K2_0805') self.r1 = Part.objects.get(name='R_2K2_0805')
+46 -5
View File
@@ -23,8 +23,8 @@ class CategoryTest(TestCase):
super().setUpTestData() super().setUpTestData()
cls.electronics = PartCategory.objects.get(name='Electronics') cls.electronics = PartCategory.objects.get(name='Electronics')
cls.mechanical = PartCategory.objects.get(name='Mechanical')
cls.resistors = PartCategory.objects.get(name='Resistors') cls.resistors = PartCategory.objects.get(name='Resistors')
cls.mechanical = PartCategory.objects.get(name='Mechanical')
cls.capacitors = PartCategory.objects.get(name='Capacitors') cls.capacitors = PartCategory.objects.get(name='Capacitors')
cls.fasteners = PartCategory.objects.get(name='Fasteners') cls.fasteners = PartCategory.objects.get(name='Fasteners')
cls.ic = PartCategory.objects.get(name='IC') cls.ic = PartCategory.objects.get(name='IC')
@@ -66,6 +66,7 @@ class CategoryTest(TestCase):
def test_path_string(self): def test_path_string(self):
"""Test that the category path string works correctly.""" """Test that the category path string works correctly."""
# Note that due to data migrations, these fields need to be saved first # Note that due to data migrations, these fields need to be saved first
self.resistors.save() self.resistors.save()
self.transceivers.save() self.transceivers.save()
@@ -88,6 +89,9 @@ class CategoryTest(TestCase):
# Move to a new parent location # Move to a new parent location
subcat.parent = self.resistors subcat.parent = self.resistors
subcat.save() subcat.save()
# subcat.refresh_from_db()
self.assertEqual(subcat.pathstring, 'Electronics/Resistors/Subcategory') self.assertEqual(subcat.pathstring, 'Electronics/Resistors/Subcategory')
self.assertEqual(len(subcat.path), 3) self.assertEqual(len(subcat.path), 3)
@@ -217,6 +221,45 @@ class CategoryTest(TestCase):
w = Part.objects.get(name='Widget') w = Part.objects.get(name='Widget')
self.assertIsNone(w.get_default_location()) 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): def test_category_tree(self):
"""Unit tests for the part category tree structure (MPTT). """Unit tests for the part category tree structure (MPTT).
@@ -226,8 +269,6 @@ class CategoryTest(TestCase):
# Clear out any existing parts # Clear out any existing parts
Part.objects.all().delete() Part.objects.all().delete()
PartCategory.objects.rebuild()
# First, create a structured tree of part categories # First, create a structured tree of part categories
A = PartCategory.objects.create(name='A', description='Top level category') 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]: for loc in [B1, B2, C31, C32, C33]:
# These should now all be "top level" categories # These should now all be "top level" categories
loc.refresh_from_db() loc.refresh_from_db()
self.assertEqual(loc.level, 0)
self.assertEqual(loc.parent, None) self.assertEqual(loc.parent, None)
self.assertEqual(loc.level, 0)
# Pathstring should be the same as the name # Pathstring should be the same as the name
self.assertEqual(loc.pathstring, loc.name) self.assertEqual(loc.pathstring, loc.name)
@@ -319,6 +359,7 @@ class CategoryTest(TestCase):
# Test pathstring for direct children # Test pathstring for direct children
for child in loc.get_children(): for child in loc.get_children():
self.assertEqual(child.pathstring, f'{loc.name}/{child.name}') self.assertEqual(child.pathstring, f'{loc.name}/{child.name}')
self.assertEqual(child.level, 1)
# Check descendants for B1 # Check descendants for B1
descendants = B1.get_descendants() descendants = B1.get_descendants()
+174 -16
View File
@@ -160,11 +160,8 @@ class PartTest(TestCase):
cls.r1 = Part.objects.get(name='R_2K2_0805') cls.r1 = Part.objects.get(name='R_2K2_0805')
cls.r2 = Part.objects.get(name='R_4K7_0603') cls.r2 = Part.objects.get(name='R_4K7_0603')
cls.c1 = Part.objects.get(name='C_22N_0805') cls.c1 = Part.objects.get(name='C_22N_0805')
Part.objects.rebuild()
def test_barcode_mixin(self): def test_barcode_mixin(self):
"""Test the barcode mixin functionality.""" """Test the barcode mixin functionality."""
self.assertEqual(Part.barcode_model_type(), 'part') self.assertEqual(Part.barcode_model_type(), 'part')
@@ -173,18 +170,6 @@ class PartTest(TestCase):
barcode = p.format_barcode() barcode = p.format_barcode()
self.assertEqual(barcode, '{"part": 1}') 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): def test_str(self):
"""Test string representation of a Part.""" """Test string representation of a Part."""
p = Part.objects.get(pk=100) p = Part.objects.get(pk=100)
@@ -417,7 +402,6 @@ class PartTest(TestCase):
) )
with self.assertRaises(ValidationError) as exc: with self.assertRaises(ValidationError) as exc:
print('rev a:', rev_a.revision_of, part.revision_of)
rev_a.revision_of = part rev_a.revision_of = part
rev_a.save() rev_a.save()
@@ -466,6 +450,180 @@ class PartTest(TestCase):
self.assertEqual(part.revisions.count(), 2) 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): class TestTemplateTest(TestCase):
"""Unit test for the TestTemplate class.""" """Unit test for the TestTemplate class."""
@@ -16,10 +16,13 @@ class SampleValidatorPluginTest(InvenTreeAPITestCase, InvenTreeTestCase):
def setUp(self): def setUp(self):
"""Set up the test environment.""" """Set up the test environment."""
super().setUp()
cat = part.models.PartCategory.objects.first() cat = part.models.PartCategory.objects.first()
self.part = part.models.Part.objects.create( self.part = part.models.Part.objects.create(
name='TestPart', category=cat, description='A test part', component=True name='TestPart', category=cat, description='A test part', component=True
) )
self.assembly = part.models.Part.objects.create( self.assembly = part.models.Part.objects.create(
name='TestAssembly', name='TestAssembly',
category=cat, category=cat,
@@ -27,10 +30,6 @@ class SampleValidatorPluginTest(InvenTreeAPITestCase, InvenTreeTestCase):
component=False, component=False,
assembly=True, 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): def get_plugin(self):
"""Return the SampleValidatorPlugin instance.""" """Return the SampleValidatorPlugin instance."""
@@ -43,6 +42,16 @@ class SampleValidatorPluginTest(InvenTreeAPITestCase, InvenTreeTestCase):
def test_validate_model_instance(self): def test_validate_model_instance(self):
"""Test the validate_model_instance function.""" """Test the validate_model_instance function."""
# First, ensure that the plugin is disabled # 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) self.enable_plugin(False)
plg = self.get_plugin() plg = self.get_plugin()
@@ -27,8 +27,8 @@
name: 'Dining Room' name: 'Dining Room'
description: 'A table lives here' description: 'A table lives here'
parent: 1 parent: 1
level: 0
tree_id: 1 tree_id: 1
level: 1
lft: 4 lft: 4
rght: 5 rght: 5
@@ -49,8 +49,8 @@
name: 'Drawer_1' name: 'Drawer_1'
description: 'In my desk' description: 'In my desk'
parent: 4 parent: 4
level: 0
tree_id: 2 tree_id: 2
level: 1
lft: 2 lft: 2
rght: 3 rght: 3
@@ -60,8 +60,8 @@
name: 'Drawer_2' name: 'Drawer_2'
description: 'Also in my desk' description: 'Also in my desk'
parent: 4 parent: 4
level: 0
tree_id: 2 tree_id: 2
level: 1
lft: 4 lft: 4
rght: 5 rght: 5
@@ -71,7 +71,7 @@
name: 'Drawer_3' name: 'Drawer_3'
description: 'Again, in my desk' description: 'Again, in my desk'
parent: 4 parent: 4
level: 0
tree_id: 2 tree_id: 2
level: 1
lft: 6 lft: 6
rght: 7 rght: 7
+91 -91
View File
@@ -8,12 +8,12 @@
location: 3 location: 3
batch: 'B123' batch: 'B123'
quantity: 4000 quantity: 4000
level: 0
tree_id: 0
lft: 0
rght: 0
purchase_price: 123 purchase_price: 123
purchase_price_currency: AUD purchase_price_currency: AUD
tree_id: 20
level: 0
lft: 1
rght: 2
# 5,000 screws in the bathroom # 5,000 screws in the bathroom
- model: stock.stockitem - model: stock.stockitem
@@ -22,10 +22,10 @@
part: 1 part: 1
location: 2 location: 2
quantity: 5000 quantity: 5000
tree_id: 21
level: 0 level: 0
tree_id: 0 lft: 1
lft: 0 rght: 2
rght: 0
# Capacitor C_22N_0805 in 'Office' # Capacitor C_22N_0805 in 'Office'
- model: stock.stockitem - model: stock.stockitem
@@ -34,10 +34,10 @@
part: 5 part: 5
location: 4 location: 4
quantity: 666 quantity: 666
tree_id: 16
level: 0 level: 0
tree_id: 0 lft: 1
lft: 0 rght: 2
rght: 0
# 1234 2K2 resistors in 'Drawer_1' # 1234 2K2 resistors in 'Drawer_1'
- model: stock.stockitem - model: stock.stockitem
@@ -46,10 +46,10 @@
part: 3 part: 3
location: 5 location: 5
quantity: 1234 quantity: 1234
tree_id: 22
level: 0 level: 0
tree_id: 0 lft: 1
lft: 0 rght: 2
rght: 0
# Some widgets in drawer 3 # Some widgets in drawer 3
- model: stock.stockitem - model: stock.stockitem
@@ -60,10 +60,10 @@
location: 7 location: 7
quantity: 10 quantity: 10
delete_on_deplete: False delete_on_deplete: False
tree_id: 28
level: 0 level: 0
tree_id: 0 lft: 1
lft: 0 rght: 2
rght: 0
- model: stock.stockitem - model: stock.stockitem
pk: 101 pk: 101
@@ -72,10 +72,10 @@
batch: "B2345" batch: "B2345"
location: 7 location: 7
quantity: 5 quantity: 5
tree_id: 27
level: 0 level: 0
tree_id: 0 lft: 1
lft: 0 rght: 2
rght: 0
- model: stock.stockitem - model: stock.stockitem
pk: 102 pk: 102
@@ -84,10 +84,10 @@
batch: 'BCDE' batch: 'BCDE'
location: 7 location: 7
quantity: 0 quantity: 0
tree_id: 26
level: 0 level: 0
tree_id: 0 lft: 1
lft: 0 rght: 2
rght: 0
- model: stock.stockitem - model: stock.stockitem
pk: 105 pk: 105
@@ -97,10 +97,10 @@
quantity: 1 quantity: 1
serial: 1000 serial: 1000
serial_int: 1000 serial_int: 1000
tree_id: 29
level: 0 level: 0
tree_id: 0 lft: 1
lft: 0 rght: 2
rght: 0
# Stock items for template / variant parts # Stock items for template / variant parts
- model: stock.stockitem - model: stock.stockitem
@@ -110,10 +110,10 @@
location: 7 location: 7
quantity: 5 quantity: 5
batch: "BBAAA" batch: "BBAAA"
tree_id: 2
level: 0 level: 0
tree_id: 0 lft: 1
lft: 0 rght: 2
rght: 0
- model: stock.stockitem - model: stock.stockitem
pk: 501 pk: 501
@@ -123,10 +123,10 @@
quantity: 1 quantity: 1
serial: 1 serial: 1
serial_int: 1 serial_int: 1
tree_id: 3
level: 0 level: 0
tree_id: 0 lft: 1
lft: 0 rght: 2
rght: 0
- model: stock.stockitem - model: stock.stockitem
pk: 502 pk: 502
@@ -136,10 +136,10 @@
quantity: 1 quantity: 1
serial: 2 serial: 2
serial_int: 2 serial_int: 2
tree_id: 4
level: 0 level: 0
tree_id: 0 lft: 1
lft: 0 rght: 2
rght: 0
- model: stock.stockitem - model: stock.stockitem
pk: 503 pk: 503
@@ -149,10 +149,10 @@
quantity: 1 quantity: 1
serial: 3 serial: 3
serial_int: 3 serial_int: 3
tree_id: 5
level: 0 level: 0
tree_id: 0 lft: 1
lft: 0 rght: 2
rght: 0
- model: stock.stockitem - model: stock.stockitem
pk: 504 pk: 504
@@ -162,10 +162,10 @@
quantity: 1 quantity: 1
serial: 4 serial: 4
serial_int: 4 serial_int: 4
tree_id: 6
level: 0 level: 0
tree_id: 0 lft: 1
lft: 0 rght: 2
rght: 0
- model: stock.stockitem - model: stock.stockitem
pk: 505 pk: 505
@@ -175,10 +175,10 @@
quantity: 1 quantity: 1
serial: 5 serial: 5
serial_int: 5 serial_int: 5
tree_id: 1
level: 0 level: 0
tree_id: 0 lft: 1
lft: 0 rght: 2
rght: 0
- model: stock.stockitem - model: stock.stockitem
pk: 510 pk: 510
@@ -188,10 +188,10 @@
quantity: 1 quantity: 1
serial: 10 serial: 10
serial_int: 10 serial_int: 10
tree_id: 24
level: 0 level: 0
tree_id: 0 lft: 1
lft: 0 rght: 2
rght: 0
- model: stock.stockitem - model: stock.stockitem
pk: 511 pk: 511
@@ -201,10 +201,10 @@
quantity: 1 quantity: 1
serial: 11 serial: 11
serial_int: 11 serial_int: 11
tree_id: 23
level: 0 level: 0
tree_id: 0 lft: 1
lft: 0 rght: 2
rght: 0
- model: stock.stockitem - model: stock.stockitem
pk: 512 pk: 512
@@ -214,10 +214,10 @@
quantity: 1 quantity: 1
serial: 12 serial: 12
serial_int: 12 serial_int: 12
tree_id: 25
level: 0 level: 0
tree_id: 0 lft: 1
lft: 0 rght: 2
rght: 0
- model: stock.stockitem - model: stock.stockitem
pk: 520 pk: 520
@@ -227,12 +227,12 @@
quantity: 1 quantity: 1
serial: 20 serial: 20
serial_int: 20 serial_int: 20
level: 0
tree_id: 0
lft: 0
rght: 0
expiry_date: "1990-10-10" expiry_date: "1990-10-10"
barcode_hash: 9e5ae7fc20568ed4814c10967bba8b65 barcode_hash: 9e5ae7fc20568ed4814c10967bba8b65
tree_id: 18
level: 0
lft: 1
rght: 2
- model: stock.stockitem - model: stock.stockitem
pk: 521 pk: 521
@@ -242,12 +242,12 @@
quantity: 1 quantity: 1
serial: 21 serial: 21
serial_int: 21 serial_int: 21
level: 0
tree_id: 0
lft: 0
rght: 0
status: 60 status: 60
barcode_hash: 1be0dfa925825c5c6c79301449e50c2d barcode_hash: 1be0dfa925825c5c6c79301449e50c2d
tree_id: 17
level: 0
lft: 1
rght: 2
- model: stock.stockitem - model: stock.stockitem
pk: 522 pk: 522
@@ -257,12 +257,12 @@
quantity: 1 quantity: 1
serial: 22 serial: 22
serial_int: 22 serial_int: 22
level: 0
tree_id: 0
lft: 0
rght: 0
expiry_date: "1990-10-10" expiry_date: "1990-10-10"
status: 70 status: 70
tree_id: 19
level: 0
lft: 1
rght: 2
# Multiple stock items for "Bob" (PK 100) # Multiple stock items for "Bob" (PK 100)
- model: stock.stockitem - model: stock.stockitem
@@ -271,10 +271,10 @@
part: 100 part: 100
location: 1 location: 1
quantity: 10 quantity: 10
tree_id: 9
level: 0 level: 0
tree_id: 0 lft: 1
lft: 0 rght: 2
rght: 0
- model: stock.stockitem - model: stock.stockitem
pk: 1001 pk: 1001
@@ -282,10 +282,10 @@
part: 100 part: 100
location: 1 location: 1
quantity: 11 quantity: 11
tree_id: 14
level: 0 level: 0
tree_id: 0 lft: 1
lft: 0 rght: 2
rght: 0
- model: stock.stockitem - model: stock.stockitem
pk: 1002 pk: 1002
@@ -293,10 +293,10 @@
part: 100 part: 100
location: 1 location: 1
quantity: 12 quantity: 12
tree_id: 8
level: 0 level: 0
tree_id: 0 lft: 1
lft: 0 rght: 2
rght: 0
- model: stock.stockitem - model: stock.stockitem
pk: 1003 pk: 1003
@@ -304,10 +304,10 @@
part: 100 part: 100
location: 1 location: 1
quantity: 13 quantity: 13
tree_id: 15
level: 0 level: 0
tree_id: 0 lft: 1
lft: 0 rght: 2
rght: 0
- model: stock.stockitem - model: stock.stockitem
pk: 1004 pk: 1004
@@ -315,10 +315,10 @@
part: 100 part: 100
location: 1 location: 1
quantity: 14 quantity: 14
tree_id: 7
level: 0 level: 0
tree_id: 0 lft: 1
lft: 0 rght: 2
rght: 0
- model: stock.stockitem - model: stock.stockitem
pk: 1005 pk: 1005
@@ -326,10 +326,10 @@
part: 100 part: 100
location: 1 location: 1
quantity: 15 quantity: 15
tree_id: 13
level: 0 level: 0
tree_id: 0 lft: 1
lft: 0 rght: 2
rght: 0
- model: stock.stockitem - model: stock.stockitem
pk: 1006 pk: 1006
@@ -337,10 +337,10 @@
part: 100 part: 100
location: 1 location: 1
quantity: 16 quantity: 16
tree_id: 12
level: 0 level: 0
tree_id: 0 lft: 1
lft: 0 rght: 2
rght: 0
- model: stock.stockitem - model: stock.stockitem
pk: 1007 pk: 1007
@@ -348,10 +348,10 @@
part: 100 part: 100
location: 7 location: 7
quantity: 17 quantity: 17
tree_id: 11
level: 0 level: 0
tree_id: 0 lft: 1
lft: 0 rght: 2
rght: 0
- model: stock.stockitem - model: stock.stockitem
pk: 1008 pk: 1008
@@ -359,7 +359,7 @@
part: 100 part: 100
location: 7 location: 7
quantity: 18 quantity: 18
tree_id: 10
level: 0 level: 0
tree_id: 0 lft: 1
lft: 0 rght: 2
rght: 0
+50 -34
View File
@@ -14,7 +14,7 @@ from django.core.validators import MinValueValidator
from django.db import models, transaction from django.db import models, transaction
from django.db.models import Q, QuerySet, Sum from django.db.models import Q, QuerySet, Sum
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.db.models.signals import post_delete, post_save, pre_delete from django.db.models.signals import post_delete, post_save
from django.db.utils import IntegrityError, OperationalError from django.db.utils import IntegrityError, OperationalError
from django.dispatch import receiver from django.dispatch import receiver
from django.urls import reverse from django.urls import reverse
@@ -23,7 +23,7 @@ from django.utils.translation import gettext_lazy as _
import structlog import structlog
from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.models import convert_money
from mptt.managers import TreeManager from mptt.managers import TreeManager
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import TreeForeignKey
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
import build.models import build.models
@@ -135,8 +135,11 @@ class StockLocationReportContext(report.mixins.BaseReportContext):
class StockLocation( class StockLocation(
InvenTree.models.PluginValidationMixin,
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeBarcodeMixin,
report.mixins.InvenTreeReportMixin, report.mixins.InvenTreeReportMixin,
InvenTree.models.PathStringMixin,
InvenTree.models.MetadataMixin,
InvenTree.models.InvenTreeTree, InvenTree.models.InvenTreeTree,
): ):
"""Organization tree for StockItem objects. """Organization tree for StockItem objects.
@@ -409,15 +412,15 @@ class StockItemReportContext(report.mixins.BaseReportContext):
class StockItem( class StockItem(
InvenTree.models.PluginValidationMixin,
InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeBarcodeMixin,
InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeNotesMixin,
StatusCodeMixin, StatusCodeMixin,
report.mixins.InvenTreeReportMixin, report.mixins.InvenTreeReportMixin,
InvenTree.models.MetadataMixin,
InvenTree.models.PluginValidationMixin,
common.models.MetaMixin, common.models.MetaMixin,
MPTTModel, InvenTree.models.MetadataMixin,
InvenTree.models.InvenTreeTree,
): ):
"""A StockItem object represents a quantity of physical instances of a part. """A StockItem object represents a quantity of physical instances of a part.
@@ -453,6 +456,11 @@ class StockItem(
verbose_name = _('Stock Item') verbose_name = _('Stock Item')
class MPTTMeta:
"""MPTT metaclass options."""
order_insertion_by = ['part']
@staticmethod @staticmethod
def get_api_url(): def get_api_url():
"""Return API url.""" """Return API url."""
@@ -600,13 +608,19 @@ class StockItem(
raise ValidationError({'part': _('Part must be specified')}) raise ValidationError({'part': _('Part must be specified')})
part = data['part'] part = data['part']
tree_id = kwargs.pop('tree_id', 0)
data['parent'] = kwargs.pop('parent', None) or data.get('parent') parent = kwargs.pop('parent', None) or data.get('parent')
data['tree_id'] = tree_id tree_id = kwargs.pop('tree_id', StockItem.getNextTreeID())
data['level'] = kwargs.pop('level', 0)
data['rght'] = kwargs.pop('rght', 0) if parent:
data['lft'] = kwargs.pop('lft', 0) # 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 # Force single quantity for each item
data['quantity'] = 1 data['quantity'] = 1
@@ -615,6 +629,13 @@ class StockItem(
data['serial'] = serial data['serial'] = serial
data['serial_int'] = StockItem.convert_serial_to_int(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 # Construct a new StockItem from the provided dict
items.append(StockItem(**data)) items.append(StockItem(**data))
@@ -622,9 +643,12 @@ class StockItem(
StockItem.objects.bulk_create(items) StockItem.objects.bulk_create(items)
# We will need to rebuild the stock item tree manually, due to the bulk_create operation # We will need to rebuild the stock item tree manually, due to the bulk_create operation
InvenTree.tasks.offload_task( if parent and parent.tree_id:
stock.tasks.rebuild_stock_item_tree, tree_id=tree_id, group='stock' # 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 the newly created StockItem objects
return StockItem.objects.filter(part=part, serial__in=serials) return StockItem.objects.filter(part=part, serial__in=serials)
@@ -1748,8 +1772,8 @@ class StockItem(
self, self,
quantity: int, quantity: int,
serials: list[str], serials: list[str],
user: User, user: Optional[User] = None,
notes: str = '', notes: Optional[str] = '',
location: Optional[StockLocation] = None, location: Optional[StockLocation] = None,
): ):
"""Split this stock item into unique serial numbers. """Split this stock item into unique serial numbers.
@@ -2085,10 +2109,17 @@ class StockItem(
self.save() self.save()
# Rebuild stock trees as required # Rebuild stock trees as required
rebuild_result = True
for tree_id in tree_ids: for tree_id in tree_ids:
InvenTree.tasks.offload_task( if not stock.tasks.rebuild_stock_item_tree(tree_id, rebuild_on_fail=False):
stock.tasks.rebuild_stock_item_tree, tree_id=tree_id, group='stock' 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 @transaction.atomic
def splitStock(self, quantity, location=None, user=None, **kwargs): 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 # Update the new stock item to ensure the tree structure is observed
new_stock.parent = self 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 # Move to the new location if specified, otherwise use current location
if location: if location:
@@ -2192,9 +2223,7 @@ class StockItem(
) )
# Rebuild the tree for this parent item # Rebuild the tree for this parent item
InvenTree.tasks.offload_task( stock.tasks.rebuild_stock_item_tree(self.tree_id)
stock.tasks.rebuild_stock_item_tree, tree_id=self.tree_id, group='stock'
)
# Attempt to reload the new item from the database # Attempt to reload the new item from the database
try: try:
@@ -2648,19 +2677,6 @@ class StockItem(
return status['passed'] >= status['total'] 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') @receiver(post_delete, sender=StockItem, dispatch_uid='stock_item_post_delete_log')
def after_delete_stock_item(sender, instance: StockItem, **kwargs): def after_delete_stock_item(sender, instance: StockItem, **kwargs):
"""Function to be executed after a StockItem object is deleted.""" """Function to be executed after a StockItem object is deleted."""
+1 -1
View File
@@ -788,7 +788,7 @@ class SerializeStockItemSerializer(serializers.Serializer):
item.serializeStock( item.serializeStock(
data['quantity'], data['quantity'],
serials, serials,
user, user=user,
notes=data.get('notes', ''), notes=data.get('notes', ''),
location=data['destination'], location=data['destination'],
) )
+45 -7
View File
@@ -7,23 +7,61 @@ tracer = trace.get_tracer(__name__)
logger = structlog.get_logger('inventree') logger = structlog.get_logger('inventree')
@tracer.start_as_current_span('rebuild_stock_item_tree') @tracer.start_as_current_span('rebuild_stock_items')
def rebuild_stock_item_tree(tree_id=None): def rebuild_stock_items():
"""Rebuild the stock tree structure. """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.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 from stock.models import StockItem
if tree_id: if tree_id:
try: try:
StockItem.objects.partial_rebuild(tree_id) 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') 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 # 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: else:
# No tree_id provided, so rebuild the entire tree # No tree_id provided, so rebuild the entire tree
StockItem.objects.rebuild() StockItem.objects.rebuild()
return True
+15 -11
View File
@@ -73,11 +73,11 @@ class StockLocationTest(StockAPITestCase):
({}, 8, 'no parameters'), ({}, 8, 'no parameters'),
({'parent': 1, 'cascade': False}, 2, 'Filter by parent, no cascading'), ({'parent': 1, 'cascade': False}, 2, 'Filter by parent, no cascading'),
({'parent': 1, 'cascade': True}, 2, 'Filter by parent, 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'), ({'cascade': False, 'depth': 10}, 3, 'Cascade with no parent, depth=10'),
( (
{'parent': 1, 'cascade': False, 'depth': 0}, {'parent': 1, 'cascade': False, 'depth': 0},
1, 0,
'Dont cascade with depth=0 and parent', '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 name=f'Location {idx}', description=f'Test location {idx}', parent=loc
) )
StockLocation.objects.rebuild()
with self.assertNumQueriesLessThan(15): with self.assertNumQueriesLessThan(15):
response = self.get(reverse('api-location-tree'), expected_code=200) response = self.get(reverse('api-location-tree'), expected_code=200)
@@ -596,13 +594,13 @@ class StockItemListTest(StockAPITestCase):
def test_filter_by_part(self): def test_filter_by_part(self):
"""Filter StockItem by Part reference.""" """Filter StockItem by Part reference."""
# 4 stock items associated with part 25
response = self.get_stock(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) response = self.get_stock(part=10004)
self.assertEqual(len(response), 3)
self.assertEqual(len(response), 12)
def test_filter_by_ipn(self): def test_filter_by_ipn(self):
"""Filter StockItem by IPN reference.""" """Filter StockItem by IPN reference."""
@@ -884,9 +882,15 @@ class StockItemListTest(StockAPITestCase):
# Part name should match # Part name should match
self.assertEqual(row['Part.Name'], item.part.name) 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 # Export stock items with a specific part
with self.export_data(self.list_url, {'part': 25}) as data_file: 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): def test_filter_by_allocated(self):
"""Test that we can filter by "allocated" status. """Test that we can filter by "allocated" status.
@@ -1034,9 +1038,9 @@ class StockItemListTest(StockAPITestCase):
# With full data # With full data
response = self.post(url, {'part': 1, 'quantity': 1}) 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}) 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 # Wrong quantities
response = self.post(url, {'part': 1, 'quantity': 'abc'}, expected_code=400) response = self.post(url, {'part': 1, 'quantity': 'abc'}, expected_code=400)
+331 -209
View File
@@ -50,89 +50,10 @@ class StockTestBase(InvenTreeTestCase):
cls.drawer2 = StockLocation.objects.get(name='Drawer_2') cls.drawer2 = StockLocation.objects.get(name='Drawer_2')
cls.drawer3 = StockLocation.objects.get(name='Drawer_3') cls.drawer3 = StockLocation.objects.get(name='Drawer_3')
# Ensure the MPTT objects are correctly rebuild
Part.objects.rebuild()
StockItem.objects.rebuild()
class StockTest(StockTestBase): class StockTest(StockTestBase):
"""Tests to ensure that the stock location tree functions correctly.""" """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): def test_link(self):
"""Test the link URL field validation.""" """Test the link URL field validation."""
item = StockItem.objects.get(pk=1) item = StockItem.objects.get(pk=1)
@@ -768,132 +689,6 @@ class StockTest(StockTestBase):
# Serialize the remainder of the stock # Serialize the remainder of the stock
item.serializeStock(2, [99, 100], self.user) 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): def test_metadata(self):
"""Unit tests for the metadata field.""" """Unit tests for the metadata field."""
for model in [StockItem, StockLocation]: for model in [StockItem, StockLocation]:
@@ -1092,7 +887,9 @@ class VariantTest(StockTestBase):
item.save() item.save()
# Attempt to create the same serial number but for a variant (should fail!) # 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.pk = None
item.tree_id = None
item.part = Part.objects.get(pk=10004) item.part = Part.objects.get(pk=10004)
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
@@ -1102,13 +899,216 @@ class VariantTest(StockTestBase):
item.save() 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): class StockTreeTest(StockTestBase):
"""Unit test for StockItem tree structure.""" """Unit test for StockItem tree structure."""
def test_stock_split(self): def test_stock_split(self):
"""Test that stock splitting works correctly.""" """Test that stock splitting works correctly."""
StockItem.objects.rebuild()
part = Part.objects.create(name='My part', description='My part description') part = Part.objects.create(name='My part', description='My part description')
location = StockLocation.objects.create(name='Test Location') location = StockLocation.objects.create(name='Test Location')
@@ -1158,6 +1158,127 @@ class StockTreeTest(StockTestBase):
item.refresh_from_db() item.refresh_from_db()
self.assertEqual(item.get_descendants(include_self=True).count(), n + 30) 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): class TestResultTest(StockTestBase):
"""Tests for the StockItemTestResult model.""" """Tests for the StockItemTestResult model."""
@@ -1245,11 +1366,12 @@ class TestResultTest(StockTestBase):
from plugin.registry import registry from plugin.registry import registry
StockItem.objects.rebuild()
item = StockItem.objects.get(pk=522) item = StockItem.objects.get(pk=522)
# Let's duplicate this item
item.pk = None item.pk = None
item.parent = None
item.tree_id = None
item.serial = None item.serial = None
item.quantity = 50 item.quantity = 50