InvenTree/InvenTree/part/models.py
2021-11-23 00:28:23 +01:00

2920 lines
91 KiB
Python

"""
Part database model definitions
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import decimal
import os
import logging
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError
from django.urls import reverse
from django.db import models, transaction
from django.db.utils import IntegrityError
from django.db.models import Q, Sum, UniqueConstraint
from django.db.models.functions import Coalesce
from django.core.validators import MinValueValidator
from django.contrib.auth.models import User
from django.db.models.signals import pre_delete, post_save
from django.dispatch import receiver
from jinja2 import Template
from markdownx.models import MarkdownxField
from django_cleanup import cleanup
from mptt.models import TreeForeignKey, MPTTModel
from mptt.exceptions import InvalidMove
from mptt.managers import TreeManager
from stdimage.models import StdImageField
from decimal import Decimal, InvalidOperation
from datetime import datetime
import hashlib
from djmoney.contrib.exchange.models import convert_money
from common.settings import currency_code_default
from common.models import InvenTreeSetting
from InvenTree import helpers
from InvenTree import validators
from InvenTree.models import InvenTreeTree, InvenTreeAttachment
from InvenTree.fields import InvenTreeURLField
from InvenTree.helpers import decimal2string, normalize, decimal2money
import InvenTree.tasks
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus
from build import models as BuildModels
from order import models as OrderModels
from company.models import SupplierPart
from stock import models as StockModels
import common.models
import part.settings as part_settings
logger = logging.getLogger("inventree")
class PartCategory(InvenTreeTree):
""" PartCategory provides hierarchical organization of Part objects.
Attributes:
name: Name of this category
parent: Parent category
default_location: Default storage location for parts in this category or child categories
default_keywords: Default keywords for parts created in this category
"""
default_location = TreeForeignKey(
'stock.StockLocation', related_name="default_categories",
null=True, blank=True,
on_delete=models.SET_NULL,
verbose_name=_('Default Location'),
help_text=_('Default location for parts in this category')
)
default_keywords = models.CharField(null=True, blank=True, max_length=250, verbose_name=_('Default keywords'), help_text=_('Default keywords for parts in this category'))
@staticmethod
def get_api_url():
return reverse('api-part-category-list')
def get_absolute_url(self):
return reverse('category-detail', kwargs={'pk': self.id})
class Meta:
verbose_name = _("Part Category")
verbose_name_plural = _("Part Categories")
def get_parts(self, cascade=True):
""" Return a queryset for all parts under this category.
args:
cascade - If True, also look under subcategories (default = True)
"""
if cascade:
""" Select any parts which exist in this category or any child categories """
queryset = Part.objects.filter(category__in=self.getUniqueChildren(include_self=True))
else:
queryset = Part.objects.filter(category=self.pk)
return queryset
@property
def item_count(self):
return self.partcount()
def partcount(self, cascade=True, active=False):
""" Return the total part count under this category
(including children of child categories)
"""
query = self.get_parts(cascade=cascade)
if active:
query = query.filter(active=True)
return query.count()
@property
def has_parts(self):
""" True if there are any parts in this category """
return self.partcount() > 0
def prefetch_parts_parameters(self, cascade=True):
""" Prefectch parts parameters """
return self.get_parts(cascade=cascade).prefetch_related('parameters', 'parameters__template').all()
def get_unique_parameters(self, cascade=True, prefetch=None):
""" Get all unique parameter names for all parts from this category """
unique_parameters_names = []
if prefetch:
parts = prefetch
else:
parts = self.prefetch_parts_parameters(cascade=cascade)
for part in parts:
for parameter in part.parameters.all():
parameter_name = parameter.template.name
if parameter_name not in unique_parameters_names:
unique_parameters_names.append(parameter_name)
return sorted(unique_parameters_names)
def get_parts_parameters(self, cascade=True, prefetch=None):
""" Get all parameter names and values for all parts from this category """
category_parameters = []
if prefetch:
parts = prefetch
else:
parts = self.prefetch_parts_parameters(cascade=cascade)
for part in parts:
part_parameters = {
'pk': part.pk,
'name': part.name,
'description': part.description,
}
# Add IPN only if it exists
if part.IPN:
part_parameters['IPN'] = part.IPN
for parameter in part.parameters.all():
parameter_name = parameter.template.name
parameter_value = parameter.data
part_parameters[parameter_name] = parameter_value
category_parameters.append(part_parameters)
return category_parameters
@classmethod
def get_parent_categories(cls):
""" Return tuple list of parent (root) categories """
# Get root nodes
root_categories = cls.objects.filter(level=0)
parent_categories = []
for category in root_categories:
parent_categories.append((category.id, category.name))
return parent_categories
def get_parameter_templates(self):
""" Return parameter templates associated to category """
prefetch = PartCategoryParameterTemplate.objects.prefetch_related('category', 'parameter_template')
return prefetch.filter(category=self.id)
def get_subscribers(self, include_parents=True):
"""
Return a list of users who subscribe to this PartCategory
"""
cats = self.get_ancestors(include_self=True)
subscribers = set()
if include_parents:
queryset = PartCategoryStar.objects.filter(
category__pk__in=[cat.pk for cat in cats]
)
else:
queryset = PartCategoryStar.objects.filter(
category=self,
)
for result in queryset:
subscribers.add(result.user)
return [s for s in subscribers]
def is_starred_by(self, user, **kwargs):
"""
Returns True if the specified user subscribes to this category
"""
return user in self.get_subscribers(**kwargs)
def set_starred(self, user, status):
"""
Set the "subscription" status of this PartCategory against the specified user
"""
if not user:
return
if self.is_starred_by(user) == status:
return
if status:
PartCategoryStar.objects.create(
category=self,
user=user
)
else:
# Note that this won't actually stop the user being subscribed,
# if the user is subscribed to a parent category
PartCategoryStar.objects.filter(
category=self,
user=user,
).delete()
@receiver(pre_delete, sender=PartCategory, dispatch_uid='partcategory_delete_log')
def before_delete_part_category(sender, instance, using, **kwargs):
""" Receives before_delete signal for PartCategory object
Before deleting, update child Part and PartCategory objects:
- For each child category, set the parent to the parent of *this* category
- For each part, set the 'category' to the parent of *this* category
"""
# Update each part in this category to point to the parent category
for part in instance.parts.all():
part.category = instance.parent
part.save()
# Update each child category
for child in instance.children.all():
child.parent = instance.parent
child.save()
def rename_part_image(instance, filename):
""" Function for renaming a part image file
Args:
instance: Instance of a Part object
filename: Name of original uploaded file
Returns:
Cleaned filename in format part_<n>_img
"""
base = 'part_images'
fname = os.path.basename(filename)
return os.path.join(base, fname)
class PartManager(TreeManager):
"""
Defines a custom object manager for the Part model.
The main purpose of this manager is to reduce the number of database hits,
as the Part model has a large number of ForeignKey fields!
"""
def get_queryset(self):
return super().get_queryset().prefetch_related(
'category',
'category__parent',
'stock_items',
'builds',
)
@cleanup.ignore
class Part(MPTTModel):
""" The Part object represents an abstract part, the 'concept' of an actual entity.
An actual physical instance of a Part is a StockItem which is treated separately.
Parts can be used to create other parts (as part of a Bill of Materials or BOM).
Attributes:
name: Brief name for this part
variant: Optional variant number for this part - Must be unique for the part name
category: The PartCategory to which this part belongs
description: Longer form description of the part
keywords: Optional keywords for improving part search results
IPN: Internal part number (optional)
revision: Part revision
is_template: If True, this part is a 'template' part
link: Link to an external page with more information about this part (e.g. internal Wiki)
image: Image of this part
default_location: Where the item is normally stored (may be null)
default_supplier: The default SupplierPart which should be used to procure and stock this part
default_expiry: The default expiry duration for any StockItem instances of this part
minimum_stock: Minimum preferred quantity to keep in stock
units: Units of measure for this part (default='pcs')
salable: Can this part be sold to customers?
assembly: Can this part be build from other parts?
component: Can this part be used to make other parts?
purchaseable: Can this part be purchased from suppliers?
trackable: Trackable parts can have unique serial numbers assigned, etc, etc
active: Is this part active? Parts are deactivated instead of being deleted
virtual: Is this part "virtual"? e.g. a software product or similar
notes: Additional notes field for this part
creation_date: Date that this part was added to the database
creation_user: User who added this part to the database
responsible: User who is responsible for this part (optional)
"""
objects = PartManager()
class Meta:
verbose_name = _("Part")
verbose_name_plural = _("Parts")
ordering = ['name', ]
constraints = [
UniqueConstraint(fields=['name', 'IPN', 'revision'], name='unique_part')
]
class MPTTMeta:
# For legacy reasons the 'variant_of' field is used to indicate the MPTT parent
parent_attr = 'variant_of'
@staticmethod
def get_api_url():
return reverse('api-part-list')
def api_instance_filters(self):
"""
Return API query filters for limiting field results against this instance
"""
return {
'variant_of': {
'exclude_tree': self.pk,
}
}
def get_context_data(self, request, **kwargs):
"""
Return some useful context data about this part for template rendering
"""
context = {}
context['disabled'] = not self.active
# Subscription status
context['starred'] = self.is_starred_by(request.user)
context['starred_directly'] = context['starred'] and self.is_starred_by(
request.user,
include_variants=False,
include_categories=False
)
# Pre-calculate complex queries so they only need to be performed once
context['total_stock'] = self.total_stock
context['quantity_being_built'] = self.quantity_being_built
context['required_build_order_quantity'] = self.required_build_order_quantity()
context['allocated_build_order_quantity'] = self.build_order_allocation_count()
context['required_sales_order_quantity'] = self.required_sales_order_quantity()
context['allocated_sales_order_quantity'] = self.sales_order_allocation_count()
context['available'] = self.available_stock
context['on_order'] = self.on_order
context['required'] = context['required_build_order_quantity'] + context['required_sales_order_quantity']
context['allocated'] = context['allocated_build_order_quantity'] + context['allocated_sales_order_quantity']
return context
def save(self, *args, **kwargs):
"""
Overrides the save() function for the Part model.
If the part image has been updated,
then check if the "old" (previous) image is still used by another part.
If not, it is considered "orphaned" and will be deleted.
"""
# Get category templates settings
add_category_templates = kwargs.pop('add_category_templates', False)
if self.pk:
previous = Part.objects.get(pk=self.pk)
# Image has been changed
if previous.image is not None and not self.image == previous.image:
# Are there any (other) parts which reference the image?
n_refs = Part.objects.filter(image=previous.image).exclude(pk=self.pk).count()
if n_refs == 0:
logger.info(f"Deleting unused image file '{previous.image}'")
previous.image.delete(save=False)
self.full_clean()
try:
super().save(*args, **kwargs)
except InvalidMove:
raise ValidationError({
'variant_of': _('Invalid choice for parent part'),
})
if add_category_templates:
# Get part category
category = self.category
if category is not None:
template_list = []
parent_categories = category.get_ancestors(include_self=True)
for category in parent_categories:
for template in category.get_parameter_templates():
# Check that template wasn't already added
if template.parameter_template not in template_list:
template_list.append(template.parameter_template)
try:
PartParameter.create(
part=self,
template=template.parameter_template,
data=template.default_value,
save=True
)
except IntegrityError:
# PartParameter already exists
pass
def __str__(self):
return f"{self.full_name} - {self.description}"
def checkAddToBOM(self, parent):
"""
Check if this Part can be added to the BOM of another part.
This will fail if:
a) The parent part is the same as this one
b) The parent part is used in the BOM for *this* part
c) The parent part is used in the BOM for any child parts under this one
Failing this check raises a ValidationError!
"""
if parent is None:
return
if self.pk == parent.pk:
raise ValidationError({'sub_part': _("Part '{p1}' is used in BOM for '{p2}' (recursive)").format(
p1=str(self),
p2=str(parent)
)})
bom_items = self.get_bom_items()
# Ensure that the parent part does not appear under any child BOM item!
for item in bom_items.all():
# Check for simple match
if item.sub_part == parent:
raise ValidationError({'sub_part': _("Part '{p1}' is used in BOM for '{p2}' (recursive)").format(
p1=str(parent),
p2=str(self)
)})
# And recursively check too
item.sub_part.checkAddToBOM(parent)
def checkIfSerialNumberExists(self, sn, exclude_self=False):
"""
Check if a serial number exists for this Part.
Note: Serial numbers must be unique across an entire Part "tree",
so here we filter by the entire tree.
"""
parts = Part.objects.filter(tree_id=self.tree_id)
stock = StockModels.StockItem.objects.filter(part__in=parts, serial=sn)
if exclude_self:
stock = stock.exclude(pk=self.pk)
return stock.exists()
def find_conflicting_serial_numbers(self, serials):
"""
For a provided list of serials, return a list of those which are conflicting.
"""
conflicts = []
for serial in serials:
if self.checkIfSerialNumberExists(serial, exclude_self=True):
conflicts.append(serial)
return conflicts
def getLatestSerialNumber(self):
"""
Return the "latest" serial number for this Part.
If *all* the serial numbers are integers, then this will return the highest one.
Otherwise, it will simply return the serial number most recently added.
Note: Serial numbers must be unique across an entire Part "tree",
so we filter by the entire tree.
"""
parts = Part.objects.filter(tree_id=self.tree_id)
stock = StockModels.StockItem.objects.filter(part__in=parts).exclude(serial=None)
# There are no matchin StockItem objects (skip further tests)
if not stock.exists():
return None
# Attempt to coerce the returned serial numbers to integers
# If *any* are not integers, fail!
try:
ordered = sorted(stock.all(), reverse=True, key=lambda n: int(n.serial))
if len(ordered) > 0:
return ordered[0].serial
# One or more of the serial numbers was non-numeric
# In this case, the "best" we can do is return the most recent
except ValueError:
return stock.last().serial
# No serial numbers found
return None
def getSerialNumberString(self, quantity=1):
"""
Return a formatted string representing the next available serial numbers,
given a certain quantity of items.
"""
latest = self.getLatestSerialNumber()
quantity = int(quantity)
# No serial numbers can be found, assume 1 as the first serial
if latest is None:
latest = 0
# Attempt to turn into an integer
try:
latest = int(latest)
except:
pass
if type(latest) is int:
if quantity >= 2:
text = '{n} - {m}'.format(n=latest + 1, m=latest + 1 + quantity)
return _('Next available serial numbers are') + ' ' + text
else:
text = str(latest + 1)
return _('Next available serial number is') + ' ' + text
else:
# Non-integer values, no option but to return latest
return _('Most recent serial number is') + ' ' + str(latest)
@property
def full_name(self):
""" Format a 'full name' for this Part based on the format PART_NAME_FORMAT defined in Inventree settings
As a failsafe option, the following is done
- IPN (if not null)
- Part name
- Part variant (if not null)
Elements are joined by the | character
"""
full_name_pattern = InvenTreeSetting.get_setting('PART_NAME_FORMAT')
try:
context = {'part': self}
template_string = Template(full_name_pattern)
full_name = template_string.render(context)
return full_name
except AttributeError as attr_err:
logger.warning(f"exception while trying to create full name for part {self.name}", attr_err)
# Fallback to default format
elements = []
if self.IPN:
elements.append(self.IPN)
elements.append(self.name)
if self.revision:
elements.append(self.revision)
return ' | '.join(elements)
def set_category(self, category):
# Ignore if the category is already the same
if self.category == category:
return
self.category = category
self.save()
def get_absolute_url(self):
""" Return the web URL for viewing this part """
return reverse('part-detail', kwargs={'pk': self.id})
def get_image_url(self):
""" Return the URL of the image for this part """
if self.image:
return helpers.getMediaUrl(self.image.url)
else:
return helpers.getBlankImage()
def get_thumbnail_url(self):
"""
Return the URL of the image thumbnail for this part
"""
if self.image:
return helpers.getMediaUrl(self.image.thumbnail.url)
else:
return helpers.getBlankThumbnail()
def validate_unique(self, exclude=None):
""" Validate that a part is 'unique'.
Uniqueness is checked across the following (case insensitive) fields:
* Name
* IPN
* Revision
e.g. there can exist multiple parts with the same name, but only if
they have a different revision or internal part number.
"""
super().validate_unique(exclude)
# User can decide whether duplicate IPN (Internal Part Number) values are allowed
allow_duplicate_ipn = common.models.InvenTreeSetting.get_setting('PART_ALLOW_DUPLICATE_IPN')
if self.IPN is not None and not allow_duplicate_ipn:
parts = Part.objects.filter(IPN__iexact=self.IPN)
parts = parts.exclude(pk=self.pk)
if parts.exists():
raise ValidationError({
'IPN': _('Duplicate IPN not allowed in part settings'),
})
def clean(self):
"""
Perform cleaning operations for the Part model
Update trackable status:
If this part is trackable, and it is used in the BOM
for a parent part which is *not* trackable,
then we will force the parent part to be trackable.
"""
super().clean()
if self.trackable:
for part in self.get_used_in().all():
if not part.trackable:
part.trackable = True
part.clean()
part.save()
name = models.CharField(
max_length=100, blank=False,
help_text=_('Part name'),
verbose_name=_('Name'),
validators=[validators.validate_part_name]
)
is_template = models.BooleanField(
default=part_settings.part_template_default,
verbose_name=_('Is Template'),
help_text=_('Is this part a template part?')
)
variant_of = models.ForeignKey(
'part.Part', related_name='variants',
null=True, blank=True,
limit_choices_to={
'is_template': True,
},
on_delete=models.SET_NULL,
help_text=_('Is this part a variant of another part?'),
verbose_name=_('Variant Of'),
)
description = models.CharField(
max_length=250, blank=False,
verbose_name=_('Description'),
help_text=_('Part description')
)
keywords = models.CharField(
max_length=250, blank=True, null=True,
verbose_name=_('Keywords'),
help_text=_('Part keywords to improve visibility in search results')
)
category = TreeForeignKey(
PartCategory, related_name='parts',
null=True, blank=True,
on_delete=models.DO_NOTHING,
verbose_name=_('Category'),
help_text=_('Part category')
)
IPN = models.CharField(
max_length=100, blank=True, null=True,
verbose_name=_('IPN'),
help_text=_('Internal Part Number'),
validators=[validators.validate_part_ipn]
)
revision = models.CharField(
max_length=100, blank=True, null=True,
help_text=_('Part revision or version number'),
verbose_name=_('Revision'),
)
link = InvenTreeURLField(
blank=True, null=True,
verbose_name=_('Link'),
help_text=_('Link to external URL')
)
image = StdImageField(
upload_to=rename_part_image,
null=True,
blank=True,
variations={'thumbnail': (128, 128)},
delete_orphans=False,
verbose_name=_('Image'),
)
default_location = TreeForeignKey(
'stock.StockLocation',
on_delete=models.SET_NULL,
blank=True, null=True,
help_text=_('Where is this item normally stored?'),
related_name='default_parts',
verbose_name=_('Default Location'),
)
def get_default_location(self):
""" Get the default location for a Part (may be None).
If the Part does not specify a default location,
look at the Category this part is in.
The PartCategory object may also specify a default stock location
"""
if self.default_location:
return self.default_location
elif self.category:
# Traverse up the category tree until we find a default location
cats = self.category.get_ancestors(ascending=True, include_self=True)
for cat in cats:
if cat.default_location:
return cat.default_location
# Default case - no default category found
return None
def get_default_supplier(self):
""" Get the default supplier part for this part (may be None).
- If the part specifies a default_supplier, return that
- If there is only one supplier part available, return that
- Else, return None
"""
if self.default_supplier:
return self.default_supplier
if self.supplier_count == 1:
return self.supplier_parts.first()
# Default to None if there are multiple suppliers to choose from
return None
default_supplier = models.ForeignKey(
SupplierPart,
on_delete=models.SET_NULL,
blank=True, null=True,
verbose_name=_('Default Supplier'),
help_text=_('Default supplier part'),
related_name='default_parts'
)
default_expiry = models.PositiveIntegerField(
default=0,
validators=[MinValueValidator(0)],
verbose_name=_('Default Expiry'),
help_text=_('Expiry time (in days) for stock items of this part'),
)
minimum_stock = models.PositiveIntegerField(
default=0, validators=[MinValueValidator(0)],
verbose_name=_('Minimum Stock'),
help_text=_('Minimum allowed stock level')
)
units = models.CharField(
max_length=20, default="",
blank=True, null=True,
verbose_name=_('Units'),
help_text=_('Stock keeping units for this part')
)
assembly = models.BooleanField(
default=part_settings.part_assembly_default,
verbose_name=_('Assembly'),
help_text=_('Can this part be built from other parts?')
)
component = models.BooleanField(
default=part_settings.part_component_default,
verbose_name=_('Component'),
help_text=_('Can this part be used to build other parts?')
)
trackable = models.BooleanField(
default=part_settings.part_trackable_default,
verbose_name=_('Trackable'),
help_text=_('Does this part have tracking for unique items?'))
purchaseable = models.BooleanField(
default=part_settings.part_purchaseable_default,
verbose_name=_('Purchaseable'),
help_text=_('Can this part be purchased from external suppliers?'))
salable = models.BooleanField(
default=part_settings.part_salable_default,
verbose_name=_('Salable'),
help_text=_("Can this part be sold to customers?"))
active = models.BooleanField(
default=True,
verbose_name=_('Active'),
help_text=_('Is this part active?'))
virtual = models.BooleanField(
default=part_settings.part_virtual_default,
verbose_name=_('Virtual'),
help_text=_('Is this a virtual part, such as a software product or license?'))
notes = MarkdownxField(
blank=True, null=True,
verbose_name=_('Notes'),
help_text=_('Part notes - supports Markdown formatting')
)
bom_checksum = models.CharField(max_length=128, blank=True, verbose_name=_('BOM checksum'), help_text=_('Stored BOM checksum'))
bom_checked_by = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True,
verbose_name=_('BOM checked by'), related_name='boms_checked')
bom_checked_date = models.DateField(blank=True, null=True, verbose_name=_('BOM checked date'))
creation_date = models.DateField(auto_now_add=True, editable=False, blank=True, null=True, verbose_name=_('Creation Date'))
creation_user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('Creation User'), related_name='parts_created')
responsible = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('Responsible'), related_name='parts_responible')
def format_barcode(self, **kwargs):
""" Return a JSON string for formatting a barcode for this Part object """
return helpers.MakeBarcode(
"part",
self.id,
{
"name": self.full_name,
"url": reverse('api-part-detail', kwargs={'pk': self.id}),
},
**kwargs
)
@property
def category_path(self):
if self.category:
return self.category.pathstring
return ''
@property
def available_stock(self):
"""
Return the total available stock.
- This subtracts stock which is already allocated to builds
"""
total = self.total_stock
total -= self.allocation_count()
return max(total, 0)
def requiring_build_orders(self):
"""
Return list of outstanding build orders which require this part
"""
# List parts that this part is required for
parts = self.get_used_in().all()
part_ids = [part.pk for part in parts]
# Now, get a list of outstanding build orders which require this part
builds = BuildModels.Build.objects.filter(
part__in=part_ids,
status__in=BuildStatus.ACTIVE_CODES
)
return builds
def required_build_order_quantity(self):
"""
Return the quantity of this part required for active build orders
"""
# List active build orders which reference this part
builds = self.requiring_build_orders()
quantity = 0
for build in builds:
bom_item = None
# List the bom lines required to make the build (including inherited ones!)
bom_items = build.part.get_bom_items().filter(sub_part=self)
# Match BOM item to build
for bom_item in bom_items:
build_quantity = build.quantity * bom_item.quantity
quantity += build_quantity
return quantity
def requiring_sales_orders(self):
"""
Return a list of sales orders which require this part
"""
orders = set()
# Get a list of line items for open orders which match this part
open_lines = OrderModels.SalesOrderLineItem.objects.filter(
order__status__in=SalesOrderStatus.OPEN,
part=self
)
for line in open_lines:
orders.add(line.order)
return orders
def required_sales_order_quantity(self):
"""
Return the quantity of this part required for active sales orders
"""
# Get a list of line items for open orders which match this part
open_lines = OrderModels.SalesOrderLineItem.objects.filter(
order__status__in=SalesOrderStatus.OPEN,
part=self
)
quantity = 0
for line in open_lines:
quantity += line.quantity
return quantity
def required_order_quantity(self):
"""
Return total required to fulfil orders
"""
return self.required_build_order_quantity() + self.required_sales_order_quantity()
@property
def quantity_to_order(self):
"""
Return the quantity needing to be ordered for this part.
Here, an "order" could be one of:
- Build Order
- Sales Order
To work out how many we need to order:
Stock on hand = self.total_stock
Required for orders = self.required_order_quantity()
Currently on order = self.on_order
Currently building = self.quantity_being_built
"""
# Total requirement
required = self.required_order_quantity()
# Subtract stock levels
required -= max(self.total_stock, self.minimum_stock)
# Subtract quantity on order
required -= self.on_order
# Subtract quantity being built
required -= self.quantity_being_built
return max(required, 0)
@property
def net_stock(self):
""" Return the 'net' stock. It takes into account:
- Stock on hand (total_stock)
- Stock on order (on_order)
- Stock allocated (allocation_count)
This number (unlike 'available_stock') can be negative.
"""
return self.total_stock - self.allocation_count() + self.on_order
def get_subscribers(self, include_variants=True, include_categories=True):
"""
Return a list of users who are 'subscribed' to this part.
A user may 'subscribe' to this part in the following ways:
a) Subscribing to the part instance directly
b) Subscribing to a template part "above" this part (if it is a variant)
c) Subscribing to the part category that this part belongs to
d) Subscribing to a parent category of the category in c)
"""
subscribers = set()
# Start by looking at direct subscriptions to a Part model
queryset = PartStar.objects.all()
if include_variants:
queryset = queryset.filter(
part__pk__in=[part.pk for part in self.get_ancestors(include_self=True)]
)
else:
queryset = queryset.filter(part=self)
for star in queryset:
subscribers.add(star.user)
if include_categories and self.category:
for sub in self.category.get_subscribers():
subscribers.add(sub)
return [s for s in subscribers]
def is_starred_by(self, user, **kwargs):
"""
Return True if the specified user subscribes to this part
"""
return user in self.get_subscribers(**kwargs)
def set_starred(self, user, status):
"""
Set the "subscription" status of this Part against the specified user
"""
if not user:
return
# Already subscribed?
if self.is_starred_by(user) == status:
return
if status:
PartStar.objects.create(part=self, user=user)
else:
# Note that this won't actually stop the user being subscribed,
# if the user is subscribed to a parent part or category
PartStar.objects.filter(part=self, user=user).delete()
def need_to_restock(self):
""" Return True if this part needs to be restocked
(either by purchasing or building).
If the allocated_stock exceeds the total_stock,
then we need to restock.
"""
return (self.total_stock + self.on_order - self.allocation_count) < self.minimum_stock
@property
def can_build(self):
""" Return the number of units that can be build with available stock
"""
# If this part does NOT have a BOM, result is simply the currently available stock
if not self.has_bom:
return 0
total = None
bom_items = self.get_bom_items().prefetch_related('sub_part__stock_items')
# Calculate the minimum number of parts that can be built using each sub-part
for item in bom_items.all():
stock = item.sub_part.available_stock
# If (by some chance) we get here but the BOM item quantity is invalid,
# ignore!
if item.quantity <= 0:
continue
n = int(stock / item.quantity)
if total is None or n < total:
total = n
if total is None:
total = 0
return max(total, 0)
@property
def active_builds(self):
""" Return a list of outstanding builds.
Builds marked as 'complete' or 'cancelled' are ignored
"""
return self.builds.filter(status__in=BuildStatus.ACTIVE_CODES)
@property
def inactive_builds(self):
""" Return a list of inactive builds
"""
return self.builds.exclude(status__in=BuildStatus.ACTIVE_CODES)
@property
def quantity_being_built(self):
"""
Return the current number of parts currently being built.
Note: This is the total quantity of Build orders, *not* the number of build outputs.
In this fashion, it is the "projected" quantity of builds
"""
builds = self.active_builds
quantity = 0
for build in builds:
# The remaining items in the build
quantity += build.remaining
return quantity
def build_order_allocations(self):
"""
Return all 'BuildItem' objects which allocate this part to Build objects
"""
return BuildModels.BuildItem.objects.filter(stock_item__part__id=self.id)
def build_order_allocation_count(self):
"""
Return the total amount of this part allocated to build orders
"""
query = self.build_order_allocations().aggregate(
total=Coalesce(
Sum(
'quantity',
output_field=models.DecimalField()
),
0,
output_field=models.DecimalField(),
)
)
return query['total']
def sales_order_allocations(self):
"""
Return all sales-order-allocation objects which allocate this part to a SalesOrder
"""
return OrderModels.SalesOrderAllocation.objects.filter(item__part__id=self.id)
def sales_order_allocation_count(self):
"""
Return the tutal quantity of this part allocated to sales orders
"""
query = self.sales_order_allocations().aggregate(
total=Coalesce(
Sum(
'quantity',
output_field=models.DecimalField(),
),
0,
output_field=models.DecimalField(),
)
)
return query['total']
def allocation_count(self):
"""
Return the total quantity of stock allocated for this part,
against both build orders and sales orders.
"""
return sum(
[
self.build_order_allocation_count(),
self.sales_order_allocation_count(),
],
)
def stock_entries(self, include_variants=True, in_stock=None):
""" Return all stock entries for this Part.
- If this is a template part, include variants underneath this.
Note: To return all stock-entries for all part variants under this one,
we need to be creative with the filtering.
"""
if include_variants:
query = StockModels.StockItem.objects.filter(part__in=self.get_descendants(include_self=True))
else:
query = self.stock_items
if in_stock is True:
query = query.filter(StockModels.StockItem.IN_STOCK_FILTER)
elif in_stock is False:
query = query.exclude(StockModels.StockItem.IN_STOCK_FILTER)
return query
def get_stock_count(self, include_variants=True):
"""
Return the total "in stock" count for this part
"""
entries = self.stock_entries(in_stock=True, include_variants=include_variants)
query = entries.aggregate(t=Coalesce(Sum('quantity'), Decimal(0)))
return query['t']
@property
def total_stock(self):
""" Return the total stock quantity for this part.
- Part may be stored in multiple locations
- If this part is a "template" (variants exist) then these are counted too
"""
return self.get_stock_count()
def get_bom_item_filter(self, include_inherited=True):
"""
Returns a query filter for all BOM items associated with this Part.
There are some considerations:
a) BOM items can be defined against *this* part
b) BOM items can be inherited from a *parent* part
We will construct a filter to grab *all* the BOM items!
Note: This does *not* return a queryset, it returns a Q object,
which can be used by some other query operation!
Because we want to keep our code DRY!
"""
bom_filter = Q(part=self)
if include_inherited:
# We wish to include parent parts
parents = self.get_ancestors(include_self=False)
# There are parents available
if parents.count() > 0:
parent_ids = [p.pk for p in parents]
parent_filter = Q(
part__id__in=parent_ids,
inherited=True
)
# OR the filters together
bom_filter |= parent_filter
return bom_filter
def get_bom_items(self, include_inherited=True):
"""
Return a queryset containing all BOM items for this part
By default, will include inherited BOM items
"""
return BomItem.objects.filter(self.get_bom_item_filter(include_inherited=include_inherited))
def get_installed_part_options(self, include_inherited=True, include_variants=True):
"""
Return a set of all Parts which can be "installed" into this part, based on the BOM.
arguments:
include_inherited - If set, include BomItem entries defined for parent parts
include_variants - If set, include variant parts for BomItems which allow variants
"""
parts = set()
for bom_item in self.get_bom_items(include_inherited=include_inherited):
if include_variants and bom_item.allow_variants:
for part in bom_item.sub_part.get_descendants(include_self=True):
parts.add(part)
else:
parts.add(bom_item.sub_part)
return parts
def get_used_in_filter(self, include_inherited=True):
"""
Return a query filter for all parts that this part is used in.
There are some considerations:
a) This part may be directly specified against a BOM for a part
b) This part may be specifed in a BOM which is then inherited by another part
Note: This function returns a Q object, not an actual queryset.
The Q object is used to filter against a list of Part objects
"""
# This is pretty expensive - we need to traverse multiple variant lists!
# TODO - In the future, could this be improved somehow?
# Keep a set of Part ID values
parts = set()
# First, grab a list of all BomItem objects which "require" this part
bom_items = BomItem.objects.filter(sub_part=self)
for bom_item in bom_items:
# Add the directly referenced part
parts.add(bom_item.part)
# Traverse down the variant tree?
if include_inherited and bom_item.inherited:
part_variants = bom_item.part.get_descendants(include_self=False)
for variant in part_variants:
parts.add(variant)
# Turn into a list of valid IDs (for matching against a Part query)
part_ids = [part.pk for part in parts]
return Q(id__in=part_ids)
def get_used_in(self, include_inherited=True):
"""
Return a queryset containing all parts this part is used in.
Includes consideration of inherited BOMs
"""
return Part.objects.filter(self.get_used_in_filter(include_inherited=include_inherited))
@property
def has_bom(self):
return self.get_bom_items().count() > 0
@property
def has_trackable_parts(self):
"""
Return True if any parts linked in the Bill of Materials are trackable.
This is important when building the part.
"""
for bom_item in self.get_bom_items().all():
if bom_item.sub_part.trackable:
return True
return False
@property
def bom_count(self):
""" Return the number of items contained in the BOM for this part """
return self.get_bom_items().count()
@property
def used_in_count(self):
""" Return the number of part BOMs that this part appears in """
return self.get_used_in().count()
def get_bom_hash(self):
""" Return a checksum hash for the BOM for this part.
Used to determine if the BOM has changed (and needs to be signed off!)
The hash is calculated by hashing each line item in the BOM.
returns a string representation of a hash object which can be compared with a stored value
"""
hash = hashlib.md5(str(self.id).encode())
# List *all* BOM items (including inherited ones!)
bom_items = self.get_bom_items().all().prefetch_related('sub_part')
for item in bom_items:
hash.update(str(item.get_item_hash()).encode())
return str(hash.digest())
def is_bom_valid(self):
""" Check if the BOM is 'valid' - if the calculated checksum matches the stored value
"""
return self.get_bom_hash() == self.bom_checksum or not self.has_bom
@transaction.atomic
def validate_bom(self, user):
""" Validate the BOM (mark the BOM as validated by the given User.
- Calculates and stores the hash for the BOM
- Saves the current date and the checking user
"""
# Validate each line item, ignoring inherited ones
bom_items = self.get_bom_items(include_inherited=False)
for item in bom_items.all():
item.validate_hash()
self.bom_checksum = self.get_bom_hash()
self.bom_checked_by = user
self.bom_checked_date = datetime.now().date()
self.save()
@transaction.atomic
def clear_bom(self):
"""
Clear the BOM items for the part (delete all BOM lines).
Note: Does *NOT* delete inherited BOM items!
"""
self.bom_items.all().delete()
def getRequiredParts(self, recursive=False, parts=None):
"""
Return a list of parts required to make this part (i.e. BOM items).
Args:
recursive: If True iterate down through sub-assemblies
parts: Set of parts already found (to prevent recursion issues)
"""
if parts is None:
parts = set()
bom_items = self.get_bom_items().all()
for bom_item in bom_items:
sub_part = bom_item.sub_part
if sub_part not in parts:
parts.add(sub_part)
if recursive:
sub_part.getRequiredParts(recursive=True, parts=parts)
return parts
def get_allowed_bom_items(self):
"""
Return a list of parts which can be added to a BOM for this part.
- Exclude parts which are not 'component' parts
- Exclude parts which this part is in the BOM for
"""
# Start with a list of all parts designated as 'sub components'
parts = Part.objects.filter(component=True)
# Exclude this part
parts = parts.exclude(id=self.id)
# Exclude any parts that this part is used *in* (to prevent recursive BOMs)
used_in = self.get_used_in().all()
parts = parts.exclude(id__in=[part.id for part in used_in])
return parts
@property
def supplier_count(self):
""" Return the number of supplier parts available for this part """
return self.supplier_parts.count()
@property
def has_pricing_info(self, internal=False):
""" Return true if there is pricing information for this part """
return self.get_price_range(internal=internal) is not None
@property
def has_complete_bom_pricing(self):
""" Return true if there is pricing information for each item in the BOM. """
use_internal = common.models.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
for item in self.get_bom_items().all().select_related('sub_part'):
if not item.sub_part.has_pricing_info(use_internal):
return False
return True
def get_price_info(self, quantity=1, buy=True, bom=True, internal=False):
""" Return a simplified pricing string for this part
Args:
quantity: Number of units to calculate price for
buy: Include supplier pricing (default = True)
bom: Include BOM pricing (default = True)
internal: Include internal pricing (default = False)
"""
price_range = self.get_price_range(quantity, buy, bom, internal)
if price_range is None:
return None
min_price, max_price = price_range
if min_price == max_price:
return min_price
min_price = normalize(min_price)
max_price = normalize(max_price)
return "{a} - {b}".format(a=min_price, b=max_price)
def get_supplier_price_range(self, quantity=1):
min_price = None
max_price = None
for supplier in self.supplier_parts.all():
price = supplier.get_price(quantity)
if price is None:
continue
if min_price is None or price < min_price:
min_price = price
if max_price is None or price > max_price:
max_price = price
if min_price is None or max_price is None:
return None
min_price = normalize(min_price)
max_price = normalize(max_price)
return (min_price, max_price)
def get_bom_price_range(self, quantity=1, internal=False, purchase=False):
""" Return the price range of the BOM for this part.
Adds the minimum price for all components in the BOM.
Note: If the BOM contains items without pricing information,
these items cannot be included in the BOM!
"""
min_price = None
max_price = None
for item in self.get_bom_items().all().select_related('sub_part'):
if item.sub_part.pk == self.pk:
logger.warning(f"WARNING: BomItem ID {item.pk} contains itself in BOM")
continue
q = decimal.Decimal(quantity)
i = decimal.Decimal(item.quantity)
prices = item.sub_part.get_price_range(q * i, internal=internal, purchase=purchase)
if prices is None:
continue
low, high = prices
if min_price is None:
min_price = 0
if max_price is None:
max_price = 0
min_price += low
max_price += high
if min_price is None or max_price is None:
return None
min_price = normalize(min_price)
max_price = normalize(max_price)
return (min_price, max_price)
def get_price_range(self, quantity=1, buy=True, bom=True, internal=False, purchase=False):
""" Return the price range for this part. This price can be either:
- Supplier price (if purchased from suppliers)
- BOM price (if built from other parts)
- Internal price (if set for the part)
- Purchase price (if set for the part)
Returns:
Minimum of the supplier, BOM, internal or purchase price. If no pricing available, returns None
"""
# only get internal price if set and should be used
if internal and self.has_internal_price_breaks:
internal_price = self.get_internal_price(quantity)
return internal_price, internal_price
# only get purchase price if set and should be used
if purchase:
purchase_price = self.get_purchase_price(quantity)
if purchase_price:
return purchase_price
buy_price_range = self.get_supplier_price_range(quantity) if buy else None
bom_price_range = self.get_bom_price_range(quantity, internal=internal) if bom else None
if buy_price_range is None:
return bom_price_range
elif bom_price_range is None:
return buy_price_range
else:
return (
min(buy_price_range[0], bom_price_range[0]),
max(buy_price_range[1], bom_price_range[1])
)
base_cost = models.DecimalField(max_digits=10, decimal_places=3, default=0, validators=[MinValueValidator(0)], verbose_name=_('base cost'), help_text=_('Minimum charge (e.g. stocking fee)'))
multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], verbose_name=_('multiple'), help_text=_('Sell multiple'))
get_price = common.models.get_price
@property
def has_price_breaks(self):
return self.price_breaks.count() > 0
@property
def price_breaks(self):
""" Return the associated price breaks in the correct order """
return self.salepricebreaks.order_by('quantity').all()
@property
def unit_pricing(self):
return self.get_price(1)
def add_price_break(self, quantity, price):
"""
Create a new price break for this part
args:
quantity - Numerical quantity
price - Must be a Money object
"""
# Check if a price break at that quantity already exists...
if self.price_breaks.filter(quantity=quantity, part=self.pk).exists():
return
PartSellPriceBreak.objects.create(
part=self,
quantity=quantity,
price=price
)
def get_internal_price(self, quantity, moq=True, multiples=True, currency=None):
return common.models.get_price(self, quantity, moq, multiples, currency, break_name='internal_price_breaks')
@property
def has_internal_price_breaks(self):
return self.internal_price_breaks.count() > 0
@property
def internal_price_breaks(self):
""" Return the associated price breaks in the correct order """
return self.internalpricebreaks.order_by('quantity').all()
@property
def internal_unit_pricing(self):
return self.get_internal_price(1)
def get_purchase_price(self, quantity):
currency = currency_code_default()
prices = [convert_money(item.purchase_price, currency).amount for item in self.stock_items.all() if item.purchase_price]
if prices:
return min(prices) * quantity, max(prices) * quantity
return None
@transaction.atomic
def copy_bom_from(self, other, clear=True, **kwargs):
"""
Copy the BOM from another part.
args:
other - The part to copy the BOM from
clear - Remove existing BOM items first (default=True)
"""
if clear:
# Remove existing BOM items
# Note: Inherited BOM items are *not* deleted!
self.bom_items.all().delete()
# Copy existing BOM items from another part
# Note: Inherited BOM Items will *not* be duplicated!!
for bom_item in other.get_bom_items(include_inherited=False).all():
# If this part already has a BomItem pointing to the same sub-part,
# delete that BomItem from this part first!
try:
existing = BomItem.objects.get(part=self, sub_part=bom_item.sub_part)
existing.delete()
except (BomItem.DoesNotExist):
pass
bom_item.part = self
bom_item.pk = None
bom_item.save()
@transaction.atomic
def copy_parameters_from(self, other, **kwargs):
clear = kwargs.get('clear', True)
if clear:
self.get_parameters().delete()
for parameter in other.get_parameters():
# If this part already has a parameter pointing to the same template,
# delete that parameter from this part first!
try:
existing = PartParameter.objects.get(part=self, template=parameter.template)
existing.delete()
except (PartParameter.DoesNotExist):
pass
parameter.part = self
parameter.pk = None
parameter.save()
@transaction.atomic
def deep_copy(self, other, **kwargs):
""" Duplicates non-field data from another part.
Does not alter the normal fields of this part,
but can be used to copy other data linked by ForeignKey refernce.
Keyword Args:
image: If True, copies Part image (default = True)
bom: If True, copies BOM data (default = False)
parameters: If True, copies Parameters data (default = True)
"""
# Copy the part image
if kwargs.get('image', True):
if other.image:
# Reference the other image from this Part
self.image = other.image
# Copy the BOM data
if kwargs.get('bom', False):
self.copy_bom_from(other)
# Copy the parameters data
if kwargs.get('parameters', True):
self.copy_parameters_from(other)
# Copy the fields that aren't available in the duplicate form
self.salable = other.salable
self.assembly = other.assembly
self.component = other.component
self.purchaseable = other.purchaseable
self.trackable = other.trackable
self.virtual = other.virtual
self.save()
def getTestTemplates(self, required=None, include_parent=True):
"""
Return a list of all test templates associated with this Part.
These are used for validation of a StockItem.
args:
required: Set to True or False to filter by "required" status
include_parent: Set to True to traverse upwards
"""
if include_parent:
tests = PartTestTemplate.objects.filter(part__in=self.get_ancestors(include_self=True))
else:
tests = self.test_templates
if required is not None:
tests = tests.filter(required=required)
return tests
def getRequiredTests(self):
# Return the tests which are required by this part
return self.getTestTemplates(required=True)
def requiredTestCount(self):
return self.getRequiredTests().count()
@property
def attachment_count(self):
""" Count the number of attachments for this part.
If the part is a variant of a template part,
include the number of attachments for the template part.
"""
return self.part_attachments.count()
@property
def part_attachments(self):
"""
Return *all* attachments for this part,
potentially including attachments for template parts
above this one.
"""
ancestors = self.get_ancestors(include_self=True)
attachments = PartAttachment.objects.filter(part__in=ancestors)
return attachments
def sales_orders(self):
""" Return a list of sales orders which reference this part """
orders = []
for line in self.sales_order_line_items.all().prefetch_related('order'):
if line.order not in orders:
orders.append(line.order)
return orders
def purchase_orders(self):
""" Return a list of purchase orders which reference this part """
orders = []
for part in self.supplier_parts.all().prefetch_related('purchase_order_line_items'):
for order in part.purchase_orders():
if order not in orders:
orders.append(order)
return orders
def open_purchase_orders(self):
""" Return a list of open purchase orders against this part """
return [order for order in self.purchase_orders() if order.status in PurchaseOrderStatus.OPEN]
def closed_purchase_orders(self):
""" Return a list of closed purchase orders against this part """
return [order for order in self.purchase_orders() if order.status not in PurchaseOrderStatus.OPEN]
@property
def on_order(self):
""" Return the total number of items on order for this part. """
orders = self.supplier_parts.filter(purchase_order_line_items__order__status__in=PurchaseOrderStatus.OPEN).aggregate(
quantity=Sum('purchase_order_line_items__quantity'),
received=Sum('purchase_order_line_items__received')
)
quantity = orders['quantity']
received = orders['received']
if quantity is None:
quantity = 0
if received is None:
received = 0
return quantity - received
def get_parameters(self):
""" Return all parameters for this part, ordered by name """
return self.parameters.order_by('template__name')
def parameters_map(self):
"""
Return a map (dict) of parameter values assocaited with this Part instance,
of the form:
{
"name_1": "value_1",
"name_2": "value_2",
}
"""
params = {}
for parameter in self.parameters.all():
params[parameter.template.name] = parameter.data
return params
@property
def has_variants(self):
""" Check if this Part object has variants underneath it. """
return self.get_all_variants().count() > 0
def get_all_variants(self):
""" Return all Part object which exist as a variant under this part. """
return self.get_descendants(include_self=False)
@property
def can_convert(self):
"""
Check if this Part can be "converted" to a different variant:
It can be converted if:
a) It has non-virtual variant parts underneath it
b) It has non-virtual template parts above it
c) It has non-virtual sibling variants
"""
return self.get_conversion_options().count() > 0
def get_conversion_options(self):
"""
Return options for converting this part to a "variant" within the same tree
a) Variants underneath this one
b) Immediate parent
c) Siblings
"""
parts = []
# Child parts
children = self.get_descendants(include_self=False)
for child in children:
parts.append(child)
# Immediate parent
if self.variant_of:
parts.append(self.variant_of)
siblings = self.get_siblings(include_self=False)
for sib in siblings:
parts.append(sib)
filtered_parts = Part.objects.filter(pk__in=[part.pk for part in parts])
# Ensure this part is not in the queryset, somehow
filtered_parts = filtered_parts.exclude(pk=self.pk)
filtered_parts = filtered_parts.filter(
active=True,
virtual=False,
)
return filtered_parts
def get_related_parts(self):
""" Return list of tuples for all related parts:
- first value is PartRelated object
- second value is matching Part object
"""
related_parts = []
related_parts_1 = self.related_parts_1.filter(part_1__id=self.pk)
related_parts_2 = self.related_parts_2.filter(part_2__id=self.pk)
for related_part in related_parts_1:
# Add to related parts list
related_parts.append((related_part, related_part.part_2))
for related_part in related_parts_2:
# Add to related parts list
related_parts.append((related_part, related_part.part_1))
return related_parts
@property
def related_count(self):
return len(self.get_related_parts())
def is_part_low_on_stock(self):
"""
Returns True if the total stock for this part is less than the minimum stock level
"""
return self.get_stock_count() < self.minimum_stock
@receiver(post_save, sender=Part, dispatch_uid='part_post_save_log')
def after_save_part(sender, instance: Part, created, **kwargs):
"""
Function to be executed after a Part is saved
"""
if not created:
# Check part stock only if we are *updating* the part (not creating it)
# Run this check in the background
InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance)
class PartAttachment(InvenTreeAttachment):
"""
Model for storing file attachments against a Part object
"""
@staticmethod
def get_api_url():
return reverse('api-part-attachment-list')
def getSubdir(self):
return os.path.join("part_files", str(self.part.id))
part = models.ForeignKey(Part, on_delete=models.CASCADE,
verbose_name=_('Part'), related_name='attachments')
class PartSellPriceBreak(common.models.PriceBreak):
"""
Represents a price break for selling this part
"""
@staticmethod
def get_api_url():
return reverse('api-part-sale-price-list')
part = models.ForeignKey(
Part, on_delete=models.CASCADE,
related_name='salepricebreaks',
limit_choices_to={'salable': True},
verbose_name=_('Part')
)
class Meta:
unique_together = ('part', 'quantity')
class PartInternalPriceBreak(common.models.PriceBreak):
"""
Represents a price break for internally selling this part
"""
@staticmethod
def get_api_url():
return reverse('api-part-internal-price-list')
part = models.ForeignKey(
Part, on_delete=models.CASCADE,
related_name='internalpricebreaks',
verbose_name=_('Part')
)
class Meta:
unique_together = ('part', 'quantity')
class PartStar(models.Model):
""" A PartStar object creates a subscription relationship between a User and a Part.
It is used to designate a Part as 'subscribed' for a given User.
Attributes:
part: Link to a Part object
user: Link to a User object
"""
part = models.ForeignKey(Part, on_delete=models.CASCADE, verbose_name=_('Part'), related_name='starred_users')
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('User'), related_name='starred_parts')
class Meta:
unique_together = [
'part',
'user'
]
class PartCategoryStar(models.Model):
"""
A PartCategoryStar creates a subscription relationship between a User and a PartCategory.
Attributes:
category: Link to a PartCategory object
user: Link to a User object
"""
category = models.ForeignKey(PartCategory, on_delete=models.CASCADE, verbose_name=_('Category'), related_name='starred_users')
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('User'), related_name='starred_categories')
class Meta:
unique_together = [
'category',
'user',
]
class PartTestTemplate(models.Model):
"""
A PartTestTemplate defines a 'template' for a test which is required to be run
against a StockItem (an instance of the Part).
The test template applies "recursively" to part variants, allowing tests to be
defined in a heirarchy.
Test names are simply strings, rather than enforcing any sort of structure or pattern.
It is up to the user to determine what tests are defined (and how they are run).
To enable generation of unique lookup-keys for each test, there are some validation tests
run on the model (refer to the validate_unique function).
"""
@staticmethod
def get_api_url():
return reverse('api-part-test-template-list')
def save(self, *args, **kwargs):
self.clean()
super().save(*args, **kwargs)
def clean(self):
self.test_name = self.test_name.strip()
self.validate_unique()
super().clean()
def validate_unique(self, exclude=None):
"""
Test that this test template is 'unique' within this part tree.
"""
if not self.part.trackable:
raise ValidationError({
'part': _('Test templates can only be created for trackable parts')
})
# Get a list of all tests "above" this one
tests = PartTestTemplate.objects.filter(
part__in=self.part.get_ancestors(include_self=True)
)
# If this item is already in the database, exclude it from comparison!
if self.pk is not None:
tests = tests.exclude(pk=self.pk)
key = self.key
for test in tests:
if test.key == key:
raise ValidationError({
'test_name': _("Test with this name already exists for this part")
})
super().validate_unique(exclude)
@property
def key(self):
""" Generate a key for this test """
return helpers.generateTestKey(self.test_name)
part = models.ForeignKey(
Part,
on_delete=models.CASCADE,
related_name='test_templates',
limit_choices_to={'trackable': True},
verbose_name=_('Part'),
)
test_name = models.CharField(
blank=False, max_length=100,
verbose_name=_("Test Name"),
help_text=_("Enter a name for the test")
)
description = models.CharField(
blank=False, null=True, max_length=100,
verbose_name=_("Test Description"),
help_text=_("Enter description for this test")
)
required = models.BooleanField(
default=True,
verbose_name=_("Required"),
help_text=_("Is this test required to pass?")
)
requires_value = models.BooleanField(
default=False,
verbose_name=_("Requires Value"),
help_text=_("Does this test require a value when adding a test result?")
)
requires_attachment = models.BooleanField(
default=False,
verbose_name=_("Requires Attachment"),
help_text=_("Does this test require a file attachment when adding a test result?")
)
def validate_template_name(name):
"""
Prevent illegal characters in "name" field for PartParameterTemplate
"""
for c in "!@#$%^&*()<>{}[].,?/\|~`_+-=\'\"":
if c in str(name):
raise ValidationError(_(f"Illegal character in template name ({c})"))
class PartParameterTemplate(models.Model):
"""
A PartParameterTemplate provides a template for key:value pairs for extra
parameters fields/values to be added to a Part.
This allows users to arbitrarily assign data fields to a Part
beyond the built-in attributes.
Attributes:
name: The name (key) of the Parameter [string]
units: The units of the Parameter [string]
"""
@staticmethod
def get_api_url():
return reverse('api-part-parameter-template-list')
def __str__(self):
s = str(self.name)
if self.units:
s += " ({units})".format(units=self.units)
return s
def validate_unique(self, exclude=None):
""" Ensure that PartParameterTemplates cannot be created with the same name.
This test should be case-insensitive (which the unique caveat does not cover).
"""
super().validate_unique(exclude)
try:
others = PartParameterTemplate.objects.filter(name__iexact=self.name).exclude(pk=self.pk)
if others.exists():
msg = _("Parameter template name must be unique")
raise ValidationError({"name": msg})
except PartParameterTemplate.DoesNotExist:
pass
name = models.CharField(
max_length=100,
verbose_name=_('Name'),
help_text=_('Parameter Name'),
unique=True,
validators=[
validate_template_name,
]
)
units = models.CharField(max_length=25, verbose_name=_('Units'), help_text=_('Parameter Units'), blank=True)
class PartParameter(models.Model):
"""
A PartParameter is a specific instance of a PartParameterTemplate. It assigns a particular parameter <key:value> pair to a part.
Attributes:
part: Reference to a single Part object
template: Reference to a single PartParameterTemplate object
data: The data (value) of the Parameter [string]
"""
@staticmethod
def get_api_url():
return reverse('api-part-parameter-list')
def __str__(self):
# String representation of a PartParameter (used in the admin interface)
return "{part} : {param} = {data}{units}".format(
part=str(self.part.full_name),
param=str(self.template.name),
data=str(self.data),
units=str(self.template.units)
)
class Meta:
# Prevent multiple instances of a parameter for a single part
unique_together = ('part', 'template')
part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='parameters', verbose_name=_('Part'), help_text=_('Parent Part'))
template = models.ForeignKey(PartParameterTemplate, on_delete=models.CASCADE, related_name='instances', verbose_name=_('Template'), help_text=_('Parameter Template'))
data = models.CharField(max_length=500, verbose_name=_('Data'), help_text=_('Parameter Value'))
@classmethod
def create(cls, part, template, data, save=False):
part_parameter = cls(part=part, template=template, data=data)
if save:
part_parameter.save()
return part_parameter
class PartCategoryParameterTemplate(models.Model):
"""
A PartCategoryParameterTemplate creates a unique relationship between a PartCategory
and a PartParameterTemplate.
Multiple PartParameterTemplate instances can be associated to a PartCategory to drive
a default list of parameter templates attached to a Part instance upon creation.
Attributes:
category: Reference to a single PartCategory object
parameter_template: Reference to a single PartParameterTemplate object
default_value: The default value for the parameter in the context of the selected
category
"""
class Meta:
constraints = [
UniqueConstraint(fields=['category', 'parameter_template'],
name='unique_category_parameter_template_pair')
]
def __str__(self):
""" String representation of a PartCategoryParameterTemplate (admin interface) """
if self.default_value:
return f'{self.category.name} | {self.parameter_template.name} | {self.default_value}'
else:
return f'{self.category.name} | {self.parameter_template.name}'
category = models.ForeignKey(PartCategory,
on_delete=models.CASCADE,
related_name='parameter_templates',
verbose_name=_('Category'),
help_text=_('Part Category'))
parameter_template = models.ForeignKey(PartParameterTemplate,
on_delete=models.CASCADE,
related_name='part_categories',
verbose_name=_('Parameter Template'),
help_text=_('Parameter Template'))
default_value = models.CharField(max_length=500,
blank=True,
verbose_name=_('Default Value'),
help_text=_('Default Parameter Value'))
class BomItem(models.Model):
""" A BomItem links a part to its component items.
A part can have a BOM (bill of materials) which defines
which parts are required (and in what quantity) to make it.
Attributes:
part: Link to the parent part (the part that will be produced)
sub_part: Link to the child part (the part that will be consumed)
quantity: Number of 'sub_parts' consumed to produce one 'part'
optional: Boolean field describing if this BomItem is optional
reference: BOM reference field (e.g. part designators)
overage: Estimated losses for a Build. Can be expressed as absolute value (e.g. '7') or a percentage (e.g. '2%')
note: Note field for this BOM item
checksum: Validation checksum for the particular BOM line item
inherited: This BomItem can be inherited by the BOMs of variant parts
allow_variants: Stock for part variants can be substituted for this BomItem
"""
@staticmethod
def get_api_url():
return reverse('api-bom-list')
def get_valid_parts_for_allocation(self):
"""
Return a list of valid parts which can be allocated against this BomItem:
- Include the referenced sub_part
- Include any directly specvified substitute parts
- If allow_variants is True, allow all variants of sub_part
"""
# Set of parts we will allow
parts = set()
parts.add(self.sub_part)
# Variant parts (if allowed)
if self.allow_variants:
for variant in self.sub_part.get_descendants(include_self=False):
parts.add(variant)
# Substitute parts
for sub in self.substitutes.all():
parts.add(sub.part)
return parts
def is_stock_item_valid(self, stock_item):
"""
Check if the provided StockItem object is "valid" for assignment against this BomItem
"""
return stock_item.part in self.get_valid_parts_for_allocation()
def get_stock_filter(self):
"""
Return a queryset filter for selecting StockItems which match this BomItem
- Allow stock from all directly specified substitute parts
- If allow_variants is True, allow all part variants
"""
return Q(part__in=[part.pk for part in self.get_valid_parts_for_allocation()])
def save(self, *args, **kwargs):
self.clean()
super().save(*args, **kwargs)
# A link to the parent part
# Each part will get a reverse lookup field 'bom_items'
part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='bom_items',
verbose_name=_('Part'),
help_text=_('Select parent part'),
limit_choices_to={
'assembly': True,
})
# A link to the child item (sub-part)
# Each part will get a reverse lookup field 'used_in'
sub_part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='used_in',
verbose_name=_('Sub part'),
help_text=_('Select part to be used in BOM'),
limit_choices_to={
'component': True,
})
# Quantity required
quantity = models.DecimalField(default=1.0, max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], verbose_name=_('Quantity'), help_text=_('BOM quantity for this BOM item'))
optional = models.BooleanField(default=False, verbose_name=_('Optional'), help_text=_("This BOM item is optional"))
overage = models.CharField(max_length=24, blank=True, validators=[validators.validate_overage],
verbose_name=_('Overage'),
help_text=_('Estimated build wastage quantity (absolute or percentage)')
)
reference = models.CharField(max_length=500, blank=True, verbose_name=_('Reference'), help_text=_('BOM item reference'))
# Note attached to this BOM line item
note = models.CharField(max_length=500, blank=True, verbose_name=_('Note'), help_text=_('BOM item notes'))
checksum = models.CharField(max_length=128, blank=True, verbose_name=_('Checksum'), help_text=_('BOM line checksum'))
inherited = models.BooleanField(
default=False,
verbose_name=_('Inherited'),
help_text=_('This BOM item is inherited by BOMs for variant parts'),
)
allow_variants = models.BooleanField(
default=False,
verbose_name=_('Allow Variants'),
help_text=_('Stock items for variant parts can be used for this BOM item')
)
def get_item_hash(self):
""" Calculate the checksum hash of this BOM line item:
The hash is calculated from the following fields:
- Part.full_name (if the part name changes, the BOM checksum is invalidated)
- Quantity
- Reference field
- Note field
- Optional field
- Inherited field
"""
# Seed the hash with the ID of this BOM item
hash = hashlib.md5(str(self.id).encode())
# Update the hash based on line information
hash.update(str(self.sub_part.id).encode())
hash.update(str(self.sub_part.full_name).encode())
hash.update(str(self.quantity).encode())
hash.update(str(self.note).encode())
hash.update(str(self.reference).encode())
hash.update(str(self.optional).encode())
hash.update(str(self.inherited).encode())
return str(hash.digest())
def validate_hash(self, valid=True):
""" Mark this item as 'valid' (store the checksum hash).
Args:
valid: If true, validate the hash, otherwise invalidate it (default = True)
"""
if valid:
self.checksum = str(self.get_item_hash())
else:
self.checksum = ''
self.save()
@property
def is_line_valid(self):
""" Check if this line item has been validated by the user """
# Ensure an empty checksum returns False
if len(self.checksum) == 0:
return False
return self.get_item_hash() == self.checksum
def clean(self):
"""
Check validity of the BomItem model.
Performs model checks beyond simple field validation.
- A part cannot refer to itself in its BOM
- A part cannot refer to a part which refers to it
- If the "sub_part" is trackable, then the "part" must be trackable too!
"""
super().clean()
try:
self.quantity = Decimal(self.quantity)
except InvalidOperation:
raise ValidationError({
'quantity': _('Must be a valid number')
})
try:
# Check for circular BOM references
if self.sub_part:
self.sub_part.checkAddToBOM(self.part)
# If the sub_part is 'trackable' then the 'quantity' field must be an integer
if self.sub_part.trackable:
if not self.quantity == int(self.quantity):
raise ValidationError({
"quantity": _("Quantity must be integer value for trackable parts")
})
# Force the upstream part to be trackable if the sub_part is trackable
if not self.part.trackable:
self.part.trackable = True
self.part.clean()
self.part.save()
else:
raise ValidationError({'sub_part': _('Sub part must be specified')})
except Part.DoesNotExist:
raise ValidationError({'sub_part': _('Sub part must be specified')})
class Meta:
verbose_name = _("BOM Item")
# Prevent duplication of parent/child rows
unique_together = ('part', 'sub_part')
def __str__(self):
return "{n} x {child} to make {parent}".format(
parent=self.part.full_name,
child=self.sub_part.full_name,
n=decimal2string(self.quantity))
def available_stock(self):
"""
Return the available stock items for the referenced sub_part
"""
query = self.sub_part.stock_items.all()
query = query.prefetch_related([
'sub_part__stock_items',
])
query = query.filter(StockModels.StockItem.IN_STOCK_FILTER).aggregate(
available=Coalesce(Sum('quantity'), 0)
)
return query['available']
def get_overage_quantity(self, quantity):
""" Calculate overage quantity
"""
# Most of the time overage string will be empty
if len(self.overage) == 0:
return 0
overage = str(self.overage).strip()
# Is the overage a numerical value?
try:
ovg = float(overage)
if ovg < 0:
ovg = 0
return ovg
except ValueError:
pass
# Is the overage a percentage?
if overage.endswith('%'):
overage = overage[:-1].strip()
try:
percent = float(overage) / 100.0
if percent > 1:
percent = 1
if percent < 0:
percent = 0
# Must be represented as a decimal
percent = Decimal(percent)
return float(percent * quantity)
except ValueError:
pass
# Default = No overage
return 0
def get_required_quantity(self, build_quantity):
""" Calculate the required part quantity, based on the supplier build_quantity.
Includes overage estimate in the returned value.
Args:
build_quantity: Number of parts to build
Returns:
Quantity required for this build (including overage)
"""
# Base quantity requirement
base_quantity = self.quantity * build_quantity
# Overage requiremet
ovrg_quantity = self.get_overage_quantity(base_quantity)
required = float(base_quantity) + float(ovrg_quantity)
return required
@property
def price_range(self, internal=False):
""" Return the price-range for this BOM item. """
# get internal price setting
use_internal = common.models.InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
prange = self.sub_part.get_price_range(self.quantity, internal=use_internal and internal)
if prange is None:
return prange
pmin, pmax = prange
if pmin == pmax:
return decimal2money(pmin)
# Convert to better string representation
pmin = decimal2money(pmin)
pmax = decimal2money(pmax)
return "{pmin} to {pmax}".format(pmin=pmin, pmax=pmax)
class BomItemSubstitute(models.Model):
"""
A BomItemSubstitute provides a specification for alternative parts,
which can be used in a bill of materials.
Attributes:
bom_item: Link to the parent BomItem instance
part: The part which can be used as a substitute
"""
class Meta:
verbose_name = _("BOM Item Substitute")
# Prevent duplication of substitute parts
unique_together = ('part', 'bom_item')
def save(self, *args, **kwargs):
self.full_clean()
super().save(*args, **kwargs)
def validate_unique(self, exclude=None):
"""
Ensure that this BomItemSubstitute is "unique":
- It cannot point to the same "part" as the "sub_part" of the parent "bom_item"
"""
super().validate_unique(exclude=exclude)
if self.part == self.bom_item.sub_part:
raise ValidationError({
"part": _("Substitute part cannot be the same as the master part"),
})
@staticmethod
def get_api_url():
return reverse('api-bom-substitute-list')
bom_item = models.ForeignKey(
BomItem,
on_delete=models.CASCADE,
related_name='substitutes',
verbose_name=_('BOM Item'),
help_text=_('Parent BOM item'),
)
part = models.ForeignKey(
Part,
on_delete=models.CASCADE,
related_name='substitute_items',
verbose_name=_('Part'),
help_text=_('Substitute part'),
limit_choices_to={
'component': True,
}
)
class PartRelated(models.Model):
""" Store and handle related parts (eg. mating connector, crimps, etc.) """
part_1 = models.ForeignKey(Part, related_name='related_parts_1',
verbose_name=_('Part 1'), on_delete=models.DO_NOTHING)
part_2 = models.ForeignKey(Part, related_name='related_parts_2',
on_delete=models.DO_NOTHING,
verbose_name=_('Part 2'), help_text=_('Select Related Part'))
def __str__(self):
return f'{self.part_1} <--> {self.part_2}'
def validate(self, part_1, part_2):
''' Validate that the two parts relationship is unique '''
validate = True
parts = Part.objects.all()
related_parts = PartRelated.objects.all()
# Check if part exist and there are not the same part
if (part_1 in parts and part_2 in parts) and (part_1.pk != part_2.pk):
# Check if relation exists already
for relation in related_parts:
if (part_1 == relation.part_1 and part_2 == relation.part_2) \
or (part_1 == relation.part_2 and part_2 == relation.part_1):
validate = False
break
else:
validate = False
return validate
def clean(self):
''' Overwrite clean method to check that relation is unique '''
validate = self.validate(self.part_1, self.part_2)
if not validate:
error_message = _('Error creating relationship: check that '
'the part is not related to itself '
'and that the relationship is unique')
raise ValidationError(error_message)