2019-04-27 12:18:07 +00:00
"""
Part database model definitions
"""
2018-04-17 08:11:34 +00:00
# -*- coding: utf-8 -*-
2017-03-25 12:07:43 +00:00
from __future__ import unicode_literals
2018-04-17 08:11:34 +00:00
import os
2021-01-27 11:31:21 +00:00
import logging
2018-04-17 08:11:34 +00:00
2018-04-27 13:23:44 +00:00
from django . utils . translation import gettext_lazy as _
from django . core . exceptions import ValidationError
2019-04-24 14:28:40 +00:00
from django . urls import reverse
2018-04-27 13:23:44 +00:00
2019-05-12 02:53:56 +00:00
from django . db import models , transaction
2020-11-04 14:52:26 +00:00
from django . db . utils import IntegrityError
2021-02-17 11:53:56 +00:00
from django . db . models import Q , Sum , UniqueConstraint
2020-04-28 00:35:19 +00:00
from django . db . models . functions import Coalesce
2017-04-16 07:05:02 +00:00
from django . core . validators import MinValueValidator
2017-03-25 12:07:43 +00:00
2019-05-04 22:46:23 +00:00
from django . contrib . auth . models import User
2018-04-14 10:33:53 +00:00
from django . db . models . signals import pre_delete
from django . dispatch import receiver
2020-01-31 10:42:30 +00:00
from markdownx . models import MarkdownxField
2020-02-10 13:29:29 +00:00
from django_cleanup import cleanup
2020-05-15 11:35:53 +00:00
from mptt . models import TreeForeignKey , MPTTModel
2019-09-08 09:19:39 +00:00
2020-04-04 04:47:05 +00:00
from stdimage . models import StdImageField
2020-03-18 09:44:45 +00:00
from decimal import Decimal
2019-05-12 06:27:50 +00:00
from datetime import datetime
2020-03-22 18:54:36 +00:00
from rapidfuzz import fuzz
2019-05-12 02:42:06 +00:00
import hashlib
2019-05-11 00:36:24 +00:00
2019-05-02 10:57:53 +00:00
from InvenTree import helpers
2019-05-10 12:52:06 +00:00
from InvenTree import validators
2020-03-22 06:59:23 +00:00
from InvenTree . models import InvenTreeTree , InvenTreeAttachment
2019-09-13 14:04:08 +00:00
from InvenTree . fields import InvenTreeURLField
2020-04-11 14:56:15 +00:00
from InvenTree . helpers import decimal2string , normalize
2019-05-18 08:04:25 +00:00
2021-02-16 23:57:17 +00:00
from InvenTree . status_codes import BuildStatus , PurchaseOrderStatus , SalesOrderStatus
2019-06-04 13:38:52 +00:00
2020-04-28 00:35:19 +00:00
from build import models as BuildModels
from order import models as OrderModels
2019-05-18 08:04:25 +00:00
from company . models import SupplierPart
2020-04-26 06:38:29 +00:00
from stock import models as StockModels
2017-03-28 12:17:56 +00:00
2020-09-17 13:22:37 +00:00
import common . models
2020-11-09 12:16:04 +00:00
import part . settings as part_settings
2020-09-17 13:22:37 +00:00
2018-04-23 11:18:35 +00:00
2021-04-10 10:08:13 +00:00
logger = logging . getLogger ( " inventree " )
2021-01-27 11:31:21 +00:00
2017-03-28 12:31:41 +00:00
class PartCategory ( InvenTreeTree ) :
2017-03-27 11:55:21 +00:00
""" PartCategory provides hierarchical organization of Part objects.
2019-05-14 07:30:24 +00:00
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
2017-03-27 11:55:21 +00:00
"""
2017-03-29 12:19:53 +00:00
2019-09-08 09:19:39 +00:00
default_location = TreeForeignKey (
2019-05-04 09:00:11 +00:00
' stock.StockLocation ' , related_name = " default_categories " ,
null = True , blank = True ,
2019-05-04 11:57:43 +00:00
on_delete = models . SET_NULL ,
2021-04-03 01:59:09 +00:00
verbose_name = _ ( ' Default Location ' ) ,
2019-11-18 21:42:10 +00:00
help_text = _ ( ' Default location for parts in this category ' )
2019-05-04 09:00:11 +00:00
)
2021-04-03 01:59:09 +00:00
default_keywords = models . CharField ( null = True , blank = True , max_length = 250 , verbose_name = _ ( ' Default keywords ' ) , help_text = _ ( ' Default keywords for parts in this category ' ) )
2019-05-14 07:30:24 +00:00
2018-04-15 01:25:57 +00:00
def get_absolute_url ( self ) :
2019-04-24 14:28:40 +00:00
return reverse ( ' category-detail ' , kwargs = { ' pk ' : self . id } )
2018-04-15 01:25:57 +00:00
2017-03-27 10:03:46 +00:00
class Meta :
2020-08-08 06:54:09 +00:00
verbose_name = _ ( " Part Category " )
verbose_name_plural = _ ( " Part Categories " )
2017-03-29 12:19:53 +00:00
2019-09-08 09:13:13 +00:00
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 """
query = Part . objects . filter ( category__in = self . getUniqueChildren ( include_self = True ) )
else :
query = Part . objects . filter ( category = self . pk )
return query
2019-05-09 10:30:23 +00:00
@property
def item_count ( self ) :
2019-06-17 14:09:42 +00:00
return self . partcount ( )
2019-05-09 10:30:23 +00:00
2019-09-08 09:13:13 +00:00
def partcount ( self , cascade = True , active = False ) :
2018-04-14 13:05:36 +00:00
""" Return the total part count under this category
( including children of child categories )
"""
2019-09-08 09:13:13 +00:00
query = self . get_parts ( cascade = cascade )
2019-06-17 13:52:49 +00:00
if active :
query = query . filter ( active = True )
return query . count ( )
2018-04-14 13:05:36 +00:00
2017-04-10 23:41:03 +00:00
@property
2018-04-16 13:26:02 +00:00
def has_parts ( self ) :
2019-04-27 12:18:07 +00:00
""" True if there are any parts in this category """
2019-09-08 09:13:13 +00:00
return self . partcount ( ) > 0
2018-04-14 10:33:53 +00:00
2020-10-01 15:03:49 +00:00
def prefetch_parts_parameters ( self , cascade = True ) :
""" Prefectch parts parameters """
2020-10-01 18:46:56 +00:00
return self . get_parts ( cascade = cascade ) . prefetch_related ( ' parameters ' , ' parameters__template ' ) . all ( )
2020-10-01 15:03:49 +00:00
def get_unique_parameters ( self , cascade = True , prefetch = None ) :
2020-09-29 21:49:53 +00:00
""" Get all unique parameter names for all parts from this category """
2020-10-01 15:03:49 +00:00
2020-09-29 21:49:53 +00:00
unique_parameters_names = [ ]
2020-09-29 21:13:08 +00:00
2020-10-01 15:03:49 +00:00
if prefetch :
parts = prefetch
else :
parts = self . prefetch_parts_parameters ( cascade = cascade )
2020-09-29 21:13:08 +00:00
for part in parts :
for parameter in part . parameters . all ( ) :
2020-09-29 21:49:53 +00:00
parameter_name = parameter . template . name
if parameter_name not in unique_parameters_names :
unique_parameters_names . append ( parameter_name )
2020-09-29 21:13:08 +00:00
2020-10-01 15:03:49 +00:00
return sorted ( unique_parameters_names )
2020-09-29 21:49:53 +00:00
2020-10-01 15:03:49 +00:00
def get_parts_parameters ( self , cascade = True , prefetch = None ) :
2020-09-29 21:49:53 +00:00
""" Get all parameter names and values for all parts from this category """
2020-10-01 15:03:49 +00:00
2020-09-29 21:49:53 +00:00
category_parameters = [ ]
2020-10-01 15:03:49 +00:00
if prefetch :
parts = prefetch
else :
parts = self . prefetch_parts_parameters ( cascade = cascade )
2020-09-29 21:49:53 +00:00
for part in parts :
part_parameters = {
2020-10-01 15:03:49 +00:00
' pk ' : part . pk ,
' name ' : part . name ,
' description ' : part . description ,
2020-09-29 21:49:53 +00:00
}
2020-10-01 15:03:49 +00:00
# Add IPN only if it exists
if part . IPN :
part_parameters [ ' IPN ' ] = part . IPN
2020-09-29 21:49:53 +00:00
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
2020-09-29 21:13:08 +00:00
2020-10-30 21:09:27 +00:00
@classmethod
def get_parent_categories ( cls ) :
""" Return tuple list of parent (root) categories """
# Get root nodes
root_categories = cls . objects . filter ( level = 0 )
2020-11-02 17:20:29 +00:00
parent_categories = [ ]
2020-10-30 21:09:27 +00:00
for category in root_categories :
parent_categories . append ( ( category . id , category . name ) )
return parent_categories
2020-10-31 13:35:47 +00:00
def get_parameter_templates ( self ) :
""" Return parameter templates associated to category """
2020-11-04 17:06:07 +00:00
prefetch = PartCategoryParameterTemplate . objects . prefetch_related ( ' category ' , ' parameter_template ' )
return prefetch . filter ( category = self . id )
2020-10-31 13:35:47 +00:00
2018-04-15 15:02:17 +00:00
2018-04-14 10:33:53 +00:00
@receiver ( pre_delete , sender = PartCategory , dispatch_uid = ' partcategory_delete_log ' )
def before_delete_part_category ( sender , instance , using , * * kwargs ) :
2019-04-27 12:18:07 +00:00
""" 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
"""
2018-04-14 10:33:53 +00:00
# Update each part in this category to point to the parent category
for part in instance . parts . all ( ) :
part . category = instance . parent
part . save ( )
2017-04-10 23:41:03 +00:00
2018-04-15 01:25:57 +00:00
# Update each child category
for child in instance . children . all ( ) :
child . parent = instance . parent
child . save ( )
2018-04-14 04:11:46 +00:00
2018-04-14 07:44:22 +00:00
def rename_part_image ( instance , filename ) :
2019-04-27 12:18:07 +00:00
""" 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
"""
2018-04-14 07:44:22 +00:00
base = ' part_images '
2020-02-10 12:04:58 +00:00
fname = os . path . basename ( filename )
2018-04-14 07:44:22 +00:00
2020-02-10 12:04:58 +00:00
return os . path . join ( base , fname )
2018-04-14 07:44:22 +00:00
2017-03-29 12:19:53 +00:00
2019-05-11 01:55:17 +00:00
def match_part_names ( match , threshold = 80 , reverse = True , compare_length = False ) :
2019-05-11 00:36:24 +00:00
""" Return a list of parts whose name matches the search term using fuzzy search.
Args :
match : Term to match against
threshold : Match percentage that must be exceeded ( default = 65 )
reverse : Ordering for search results ( default = True - highest match is first )
2019-05-11 01:55:17 +00:00
compare_length : Include string length checks
2019-05-11 00:36:24 +00:00
Returns :
A sorted dict where each element contains the following key : value pairs :
- ' part ' : The matched part
- ' ratio ' : The matched ratio
"""
2019-05-11 01:55:17 +00:00
match = str ( match ) . strip ( ) . lower ( )
if len ( match ) == 0 :
return [ ]
2019-05-11 00:36:24 +00:00
parts = Part . objects . all ( )
matches = [ ]
for part in parts :
2019-05-11 01:55:17 +00:00
compare = str ( part . name ) . strip ( ) . lower ( )
if len ( compare ) == 0 :
continue
2019-05-11 02:29:02 +00:00
ratio = fuzz . partial_token_sort_ratio ( compare , match )
2019-05-11 01:55:17 +00:00
if compare_length :
# Also employ primitive length comparison
2019-05-26 02:16:57 +00:00
# TODO - Improve this somewhat...
2019-05-11 01:55:17 +00:00
l_min = min ( len ( match ) , len ( compare ) )
l_max = max ( len ( match ) , len ( compare ) )
2019-05-11 00:36:24 +00:00
2019-05-11 01:55:17 +00:00
ratio * = ( l_min / l_max )
2019-05-11 00:36:24 +00:00
if ratio > = threshold :
matches . append ( {
' part ' : part ,
2020-10-28 12:48:35 +00:00
' ratio ' : round ( ratio , 1 )
2019-05-11 00:36:24 +00:00
} )
matches = sorted ( matches , key = lambda item : item [ ' ratio ' ] , reverse = reverse )
return matches
2020-02-10 13:29:29 +00:00
@cleanup.ignore
2020-05-15 11:35:53 +00:00
class Part ( MPTTModel ) :
2019-05-10 10:11:52 +00:00
""" 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
2019-05-10 12:17:13 +00:00
variant : Optional variant number for this part - Must be unique for the part name
2019-05-10 10:11:52 +00:00
category : The PartCategory to which this part belongs
2019-05-14 07:23:20 +00:00
description : Longer form description of the part
keywords : Optional keywords for improving part search results
2019-05-10 10:11:52 +00:00
IPN : Internal part number ( optional )
2019-06-20 11:37:25 +00:00
revision : Part revision
2021-01-04 12:36:11 +00:00
is_template : If True , this part is a ' template ' part
2020-04-06 01:16:39 +00:00
link : Link to an external page with more information about this part ( e . g . internal Wiki )
2019-05-10 10:11:52 +00:00
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
2021-01-04 12:36:11 +00:00
default_expiry : The default expiry duration for any StockItem instances of this part
2019-05-10 10:11:52 +00:00
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 ?
2019-06-02 09:46:30 +00:00
assembly : Can this part be build from other parts ?
component : Can this part be used to make other parts ?
2019-05-10 10:11:52 +00:00
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
2019-06-18 08:34:07 +00:00
virtual : Is this part " virtual " ? e . g . a software product or similar
2019-05-10 10:11:52 +00:00
notes : Additional notes field for this part
2020-03-18 10:50:18 +00:00
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 )
2018-04-30 12:30:15 +00:00
"""
2017-03-29 12:19:53 +00:00
2019-05-10 12:17:13 +00:00
class Meta :
2020-05-15 11:35:53 +00:00
verbose_name = _ ( " Part " )
verbose_name_plural = _ ( " Parts " )
2020-08-08 06:59:48 +00:00
ordering = [ ' name ' , ]
2020-05-15 11:35:53 +00:00
class MPTTMeta :
# For legacy reasons the 'variant_of' field is used to indicate the MPTT parent
2020-05-15 22:55:19 +00:00
parent_attr = ' variant_of '
2019-05-10 12:17:13 +00:00
2021-02-23 22:05:31 +00:00
def get_context_data ( self , request , * * kwargs ) :
"""
Return some useful context data about this part for template rendering
"""
context = { }
context [ ' starred ' ] = self . isStarredBy ( request . user )
context [ ' disabled ' ] = not self . active
# 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
2021-05-06 10:11:38 +00:00
2021-02-23 22:05:31 +00:00
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
2020-02-11 09:27:06 +00:00
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 .
"""
2020-11-04 14:52:26 +00:00
# Get category templates settings
add_category_templates = kwargs . pop ( ' add_category_templates ' , None )
2020-02-11 09:27:06 +00:00
if self . pk :
previous = Part . objects . get ( pk = self . pk )
2021-01-27 11:31:21 +00:00
# Image has been changed
if previous . image is not None and not self . image == previous . image :
2020-02-11 09:27:06 +00:00
# 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 :
2021-01-27 11:31:21 +00:00
logger . info ( f " Deleting unused image file ' { previous . image } ' " )
2020-02-11 09:27:06 +00:00
previous . image . delete ( save = False )
2020-05-29 02:40:40 +00:00
self . clean ( )
self . validate_unique ( )
2020-02-11 09:27:06 +00:00
super ( ) . save ( * args , * * kwargs )
2020-11-04 14:52:26 +00:00
if add_category_templates :
# Get part category
category = self . category
2020-11-12 21:52:22 +00:00
if category and add_category_templates :
2020-11-04 14:52:26 +00:00
# Store templates added to part
template_list = [ ]
# Create part parameters for selected category
category_templates = add_category_templates [ ' main ' ]
if category_templates :
for template in category . get_parameter_templates ( ) :
parameter = PartParameter . create ( part = self ,
template = template . parameter_template ,
data = template . default_value ,
save = True )
if parameter :
template_list . append ( template . parameter_template )
# Create part parameters for parent category
category_templates = add_category_templates [ ' parent ' ]
if category_templates :
# Get parent categories
parent_categories = category . get_ancestors ( )
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 :
try :
PartParameter . create ( part = self ,
template = template . parameter_template ,
data = template . default_value ,
save = True )
except IntegrityError :
# PartParameter already exists
pass
2019-05-10 12:17:13 +00:00
def __str__ ( self ) :
2020-08-24 16:41:14 +00:00
return f " { self . full_name } - { self . description } "
2019-05-10 12:17:13 +00:00
2020-08-18 04:17:59 +00:00
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
2021-05-06 10:11:38 +00:00
2020-08-18 04:17:59 +00:00
Failing this check raises a ValidationError !
"""
if parent is None :
return
if self . pk == parent . pk :
2021-04-04 20:44:14 +00:00
raise ValidationError ( { ' sub_part ' : _ ( " Part ' {p1} ' is used in BOM for ' {p2} ' (recursive) " ) . format (
2020-08-18 04:17:59 +00:00
p1 = str ( self ) ,
p2 = str ( parent )
2021-04-04 20:44:14 +00:00
) } )
2020-08-18 04:17:59 +00:00
2021-02-17 11:53:56 +00:00
bom_items = self . get_bom_items ( )
2020-08-18 04:17:59 +00:00
# Ensure that the parent part does not appear under any child BOM item!
2021-02-17 11:53:56 +00:00
for item in bom_items . all ( ) :
2020-08-18 04:17:59 +00:00
# Check for simple match
if item . sub_part == parent :
2021-04-04 20:44:14 +00:00
raise ValidationError ( { ' sub_part ' : _ ( " Part ' {p1} ' is used in BOM for ' {p2} ' (recursive) " ) . format (
2020-08-18 04:17:59 +00:00
p1 = str ( parent ) ,
p2 = str ( self )
2021-04-04 20:44:14 +00:00
) } )
2020-08-18 04:17:59 +00:00
# And recursively check too
item . sub_part . checkAddToBOM ( parent )
2020-10-28 12:33:33 +00:00
def checkIfSerialNumberExists ( self , sn , exclude_self = False ) :
2020-05-15 22:43:57 +00:00
"""
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 )
2020-10-28 12:33:33 +00:00
2020-05-15 22:43:57 +00:00
stock = StockModels . StockItem . objects . filter ( part__in = parts , serial = sn )
2020-10-28 12:33:33 +00:00
if exclude_self :
stock = stock . exclude ( pk = self . pk )
2020-05-15 22:43:57 +00:00
return stock . exists ( )
2020-10-28 12:33:33 +00:00
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
2020-09-01 10:16:46 +00:00
def getLatestSerialNumber ( self ) :
2020-05-15 22:43:57 +00:00
"""
2020-09-01 10:16:46 +00:00
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 .
2020-05-15 22:43:57 +00:00
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 )
2020-08-24 18:49:32 +00:00
stock = StockModels . StockItem . objects . filter ( part__in = parts ) . exclude ( serial = None )
2021-05-06 10:11:38 +00:00
2020-09-01 10:16:46 +00:00
# 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!
2020-08-24 18:49:32 +00:00
try :
ordered = sorted ( stock . all ( ) , reverse = True , key = lambda n : int ( n . serial ) )
if len ( ordered ) > 0 :
return ordered [ 0 ] . serial
2020-09-01 10:16:46 +00:00
# One or more of the serial numbers was non-numeric
# In this case, the "best" we can do is return the most recent
2020-08-24 18:49:32 +00:00
except ValueError :
2020-09-01 10:16:46 +00:00
return stock . last ( ) . serial
2020-08-24 18:49:32 +00:00
2020-05-15 22:43:57 +00:00
# No serial numbers found
2020-09-01 10:16:46 +00:00
return None
2020-05-15 22:43:57 +00:00
2020-09-01 10:16:46 +00:00
def getSerialNumberString ( self , quantity = 1 ) :
2020-05-15 22:43:57 +00:00
"""
2020-09-01 10:16:46 +00:00
Return a formatted string representing the next available serial numbers ,
given a certain quantity of items .
2020-05-15 22:43:57 +00:00
"""
2020-09-01 10:16:46 +00:00
latest = self . getLatestSerialNumber ( )
2020-05-15 22:43:57 +00:00
2020-09-01 10:16:46 +00:00
quantity = int ( quantity )
2020-05-15 22:43:57 +00:00
2020-09-01 10:16:46 +00:00
# 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
2020-05-16 07:52:25 +00:00
2020-09-01 10:16:46 +00:00
if type ( latest ) is int :
2020-05-16 07:52:25 +00:00
2020-09-01 10:16:46 +00:00
if quantity > = 2 :
2020-09-01 10:24:14 +00:00
text = ' {n} - {m} ' . format ( n = latest + 1 , m = latest + 1 + quantity )
2020-09-01 10:16:46 +00:00
return _ ( ' Next available serial numbers are ' ) + ' ' + text
else :
2020-10-02 03:54:23 +00:00
text = str ( latest + 1 )
2020-09-01 10:16:46 +00:00
return _ ( ' Next available serial number is ' ) + ' ' + text
2020-08-24 19:00:19 +00:00
2020-05-16 07:52:25 +00:00
else :
2020-09-01 10:16:46 +00:00
# Non-integer values, no option but to return latest
2020-05-16 07:52:25 +00:00
2020-09-01 10:16:46 +00:00
return _ ( ' Most recent serial number is ' ) + ' ' + str ( latest )
2020-05-16 07:52:25 +00:00
2019-05-10 12:17:13 +00:00
@property
2019-05-12 02:16:04 +00:00
def full_name ( self ) :
2019-05-12 02:29:16 +00:00
""" Format a ' full name ' for this Part.
2019-05-12 02:16:04 +00:00
- IPN ( if not null )
- Part name
- Part variant ( if not null )
2019-05-12 02:29:16 +00:00
Elements are joined by the | character
2019-05-12 02:16:04 +00:00
"""
elements = [ ]
if self . IPN :
elements . append ( self . IPN )
2021-05-06 10:11:38 +00:00
2019-05-12 02:16:04 +00:00
elements . append ( self . name )
2019-06-20 11:46:16 +00:00
if self . revision :
elements . append ( self . revision )
2019-05-12 02:16:04 +00:00
return ' | ' . join ( elements )
2019-05-10 12:17:13 +00:00
2019-06-25 09:15:39 +00:00
def set_category ( self , category ) :
2019-09-08 10:36:51 +00:00
# Ignore if the category is already the same
if self . category == category :
return
2019-06-25 09:15:39 +00:00
self . category = category
self . save ( )
2018-04-14 15:18:12 +00:00
def get_absolute_url ( self ) :
2019-05-08 05:25:28 +00:00
""" Return the web URL for viewing this part """
2019-04-24 14:59:34 +00:00
return reverse ( ' part-detail ' , kwargs = { ' pk ' : self . id } )
2018-04-14 15:18:12 +00:00
2019-05-08 05:25:28 +00:00
def get_image_url ( self ) :
""" Return the URL of the image for this part """
if self . image :
2020-04-07 01:38:57 +00:00
return helpers . getMediaUrl ( self . image . url )
2019-05-08 05:25:28 +00:00
else :
2020-04-07 01:38:57 +00:00
return helpers . getBlankImage ( )
2019-05-08 05:25:28 +00:00
2020-04-04 13:19:05 +00:00
def get_thumbnail_url ( self ) :
"""
Return the URL of the image thumbnail for this part
"""
if self . image :
2020-04-07 01:38:57 +00:00
return helpers . getMediaUrl ( self . image . thumbnail . url )
2020-04-04 13:19:05 +00:00
else :
2020-04-07 01:38:57 +00:00
return helpers . getBlankThumbnail ( )
2020-04-07 01:40:10 +00:00
2019-06-02 10:37:59 +00:00
def validate_unique ( self , exclude = None ) :
2019-06-20 11:37:25 +00:00
""" 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 .
"""
2019-06-02 10:37:59 +00:00
super ( ) . validate_unique ( exclude )
2020-11-09 22:03:26 +00:00
# User can decide whether duplicate IPN (Internal Part Number) values are allowed
allow_duplicate_ipn = common . models . InvenTreeSetting . get_setting ( ' PART_ALLOW_DUPLICATE_IPN ' )
2020-12-16 10:02:53 +00:00
if self . IPN is not None and not allow_duplicate_ipn :
2020-11-09 22:03:26 +00:00
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 ' ) ,
} )
2019-06-02 10:37:59 +00:00
# Part name uniqueness should be case insensitive
try :
2019-06-20 11:37:25 +00:00
parts = Part . objects . exclude ( id = self . id ) . filter (
name__iexact = self . name ,
2019-06-20 11:46:16 +00:00
IPN__iexact = self . IPN ,
revision__iexact = self . revision )
2019-06-20 11:37:25 +00:00
if parts . exists ( ) :
msg = _ ( " Part must be unique for name, IPN and revision " )
2019-06-02 10:37:59 +00:00
raise ValidationError ( {
2019-06-20 11:37:25 +00:00
" name " : msg ,
" IPN " : msg ,
2019-06-20 11:46:16 +00:00
" revision " : msg ,
2019-06-02 10:37:59 +00:00
} )
except Part . DoesNotExist :
pass
2019-05-25 12:43:47 +00:00
def clean ( self ) :
2020-10-25 21:29:06 +00:00
"""
Perform cleaning operations for the Part model
2021-05-06 10:11:38 +00:00
2020-10-25 21:29:06 +00:00
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 .
"""
2019-05-25 12:43:47 +00:00
2020-05-29 02:40:40 +00:00
super ( ) . clean ( )
2019-05-25 12:43:47 +00:00
2020-10-25 21:29:06 +00:00
if self . trackable :
2021-02-23 05:49:09 +00:00
for part in self . get_used_in ( ) . all ( ) :
if not part . trackable :
part . trackable = True
part . clean ( )
part . save ( )
2020-10-25 21:29:06 +00:00
2021-01-03 12:06:51 +00:00
name = models . CharField (
max_length = 100 , blank = False ,
help_text = _ ( ' Part name ' ) ,
verbose_name = _ ( ' Name ' ) ,
validators = [ validators . validate_part_name ]
)
2019-05-10 12:17:13 +00:00
2021-01-03 12:06:51 +00:00
is_template = models . BooleanField (
default = part_settings . part_template_default ,
verbose_name = _ ( ' Is Template ' ) ,
help_text = _ ( ' Is this part a template part? ' )
)
2019-05-25 12:27:36 +00:00
2021-01-03 12:06:51 +00:00
variant_of = models . ForeignKey (
' part.Part ' , related_name = ' variants ' ,
null = True , blank = True ,
limit_choices_to = {
' is_template ' : True ,
' active ' : True ,
} ,
on_delete = models . SET_NULL ,
help_text = _ ( ' Is this part a variant of another part? ' ) ,
verbose_name = _ ( ' Variant Of ' ) ,
)
2019-05-25 12:27:36 +00:00
2021-01-03 12:06:51 +00:00
description = models . CharField (
max_length = 250 , blank = False ,
verbose_name = _ ( ' Description ' ) ,
help_text = _ ( ' Part description ' )
)
2017-04-01 02:31:48 +00:00
2021-01-03 12:06:51 +00:00
keywords = models . CharField (
max_length = 250 , blank = True , null = True ,
verbose_name = _ ( ' Keywords ' ) ,
help_text = _ ( ' Part keywords to improve visibility in search results ' )
)
2019-05-14 07:23:20 +00:00
2021-01-03 12:06:51 +00:00
category = TreeForeignKey (
PartCategory , related_name = ' parts ' ,
null = True , blank = True ,
on_delete = models . DO_NOTHING ,
verbose_name = _ ( ' Category ' ) ,
help_text = _ ( ' Part category ' )
)
2017-04-01 02:31:48 +00:00
2021-01-03 12:06:51 +00:00
IPN = models . CharField (
max_length = 100 , blank = True , null = True ,
verbose_name = _ ( ' IPN ' ) ,
help_text = _ ( ' Internal Part Number ' ) ,
validators = [ validators . validate_part_ipn ]
)
2019-05-10 10:11:52 +00:00
2021-01-03 12:06:51 +00:00
revision = models . CharField (
max_length = 100 , blank = True , null = True ,
help_text = _ ( ' Part revision or version number ' ) ,
verbose_name = _ ( ' Revision ' ) ,
)
2019-06-20 11:46:16 +00:00
2021-01-03 12:06:51 +00:00
link = InvenTreeURLField (
blank = True , null = True ,
verbose_name = _ ( ' Link ' ) ,
help_text = _ ( ' Link to external URL ' )
)
2019-05-10 10:11:52 +00:00
2020-04-04 04:47:05 +00:00
image = StdImageField (
upload_to = rename_part_image ,
null = True ,
blank = True ,
2020-04-04 13:19:05 +00:00
variations = { ' thumbnail ' : ( 128 , 128 ) } ,
2021-01-27 11:31:21 +00:00
delete_orphans = False ,
2021-04-04 20:44:14 +00:00
verbose_name = _ ( ' Image ' ) ,
2020-04-04 13:38:25 +00:00
)
2018-04-14 07:44:22 +00:00
2021-01-03 12:07:21 +00:00
default_location = TreeForeignKey (
' stock.StockLocation ' ,
2021-01-03 12:06:51 +00:00
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 ' ) ,
)
2018-04-17 08:23:24 +00:00
2019-05-04 09:06:39 +00:00
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 .
2019-05-04 11:57:43 +00:00
The PartCategory object may also specify a default stock location
2019-05-04 09:06:39 +00:00
"""
if self . default_location :
return self . default_location
elif self . category :
2019-05-04 11:56:18 +00:00
# Traverse up the category tree until we find a default location
2019-09-08 09:28:40 +00:00
cats = self . category . get_ancestors ( ascending = True , include_self = True )
2019-05-04 09:06:39 +00:00
2019-09-08 09:28:40 +00:00
for cat in cats :
2019-09-08 09:41:54 +00:00
if cat . default_location :
2019-05-04 11:56:18 +00:00
return cat . default_location
# Default case - no default category found
2019-05-04 09:06:39 +00:00
return None
2019-05-14 14:22:10 +00:00
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 :
2019-06-11 13:37:32 +00:00
return self . default_supplier
2019-05-14 14:22:10 +00:00
if self . supplier_count == 1 :
return self . supplier_parts . first ( )
# Default to None if there are multiple suppliers to choose from
return None
2021-01-04 12:36:11 +00:00
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 ' ) ,
)
2018-04-17 08:23:24 +00:00
2021-01-04 12:36:11 +00:00
minimum_stock = models . PositiveIntegerField (
default = 0 , validators = [ MinValueValidator ( 0 ) ] ,
verbose_name = _ ( ' Minimum Stock ' ) ,
help_text = _ ( ' Minimum allowed stock level ' )
)
2017-04-01 02:31:48 +00:00
2021-01-04 12:36:11 +00:00
units = models . CharField (
max_length = 20 , default = " " ,
blank = True , null = True ,
verbose_name = _ ( ' Units ' ) ,
help_text = _ ( ' Stock keeping units for this part ' )
)
2017-04-01 02:31:48 +00:00
2020-11-09 12:16:04 +00:00
assembly = models . BooleanField (
2021-01-03 12:06:51 +00:00
default = part_settings . part_assembly_default ,
2020-11-09 12:16:04 +00:00
verbose_name = _ ( ' Assembly ' ) ,
help_text = _ ( ' Can this part be built from other parts? ' )
)
2018-04-16 12:13:31 +00:00
2020-11-09 12:16:04 +00:00
component = models . BooleanField (
2020-11-09 12:44:54 +00:00
default = part_settings . part_component_default ,
2020-11-09 12:16:04 +00:00
verbose_name = _ ( ' Component ' ) ,
help_text = _ ( ' Can this part be used to build other parts? ' )
)
2019-04-15 14:01:15 +00:00
2020-11-09 12:16:04 +00:00
trackable = models . BooleanField (
default = part_settings . part_trackable_default ,
verbose_name = _ ( ' Trackable ' ) ,
help_text = _ ( ' Does this part have tracking for unique items? ' ) )
2017-03-29 12:19:53 +00:00
2020-11-09 12:16:04 +00:00
purchaseable = models . BooleanField (
default = part_settings . part_purchaseable_default ,
verbose_name = _ ( ' Purchaseable ' ) ,
help_text = _ ( ' Can this part be purchased from external suppliers? ' ) )
2018-04-15 14:30:57 +00:00
2020-11-09 12:16:04 +00:00
salable = models . BooleanField (
default = part_settings . part_salable_default ,
verbose_name = _ ( ' Salable ' ) ,
help_text = _ ( " Can this part be sold to customers? " ) )
2018-04-17 08:11:34 +00:00
2020-11-09 12:16:04 +00:00
active = models . BooleanField (
default = True ,
verbose_name = _ ( ' Active ' ) ,
help_text = _ ( ' Is this part active? ' ) )
2019-04-28 13:00:38 +00:00
2020-11-09 12:16:04 +00:00
virtual = models . BooleanField (
2021-01-03 12:13:58 +00:00
default = part_settings . part_virtual_default ,
2020-11-09 12:16:04 +00:00
verbose_name = _ ( ' Virtual ' ) ,
help_text = _ ( ' Is this a virtual part, such as a software product or license? ' ) )
2019-06-18 08:34:07 +00:00
2021-01-03 12:13:58 +00:00
notes = MarkdownxField (
blank = True , null = True ,
verbose_name = _ ( ' Notes ' ) ,
help_text = _ ( ' Part notes - supports Markdown formatting ' )
)
2018-04-17 15:44:55 +00:00
2021-04-03 01:59:09 +00:00
bom_checksum = models . CharField ( max_length = 128 , blank = True , verbose_name = _ ( ' BOM checksum ' ) , help_text = _ ( ' Stored BOM checksum ' ) )
2019-05-12 02:47:28 +00:00
bom_checked_by = models . ForeignKey ( User , on_delete = models . SET_NULL , blank = True , null = True ,
2021-04-03 01:59:09 +00:00
verbose_name = _ ( ' BOM checked by ' ) , related_name = ' boms_checked ' )
2019-05-12 02:47:28 +00:00
2021-04-03 01:59:09 +00:00
bom_checked_date = models . DateField ( blank = True , null = True , verbose_name = _ ( ' BOM checked date ' ) )
2019-05-12 02:47:28 +00:00
2021-04-03 01:59:09 +00:00
creation_date = models . DateField ( auto_now_add = True , editable = False , blank = True , null = True , verbose_name = _ ( ' Creation Date ' ) )
2020-03-18 10:50:18 +00:00
2021-04-03 01:59:09 +00:00
creation_user = models . ForeignKey ( User , on_delete = models . SET_NULL , blank = True , null = True , verbose_name = _ ( ' Creation User ' ) , related_name = ' parts_created ' )
2020-03-18 10:50:18 +00:00
2021-04-03 01:59:09 +00:00
responsible = models . ForeignKey ( User , on_delete = models . SET_NULL , blank = True , null = True , verbose_name = _ ( ' Responsible ' ) , related_name = ' parts_responible ' )
2020-03-18 10:50:18 +00:00
2020-08-16 03:39:05 +00:00
def format_barcode ( self , * * kwargs ) :
2019-05-02 10:57:53 +00:00
""" Return a JSON string for formatting a barcode for this Part object """
return helpers . MakeBarcode (
2020-04-14 11:30:43 +00:00
" part " ,
2020-08-16 03:39:05 +00:00
self . id ,
2019-05-04 13:35:52 +00:00
{
2020-04-14 11:30:43 +00:00
" name " : self . full_name ,
" url " : reverse ( ' api-part-detail ' , kwargs = { ' pk ' : self . id } ) ,
2020-08-16 03:39:05 +00:00
} ,
* * kwargs
2019-05-02 10:57:53 +00:00
)
2018-04-24 07:54:08 +00:00
@property
def category_path ( self ) :
if self . category :
return self . category . pathstring
return ' '
2018-04-15 14:30:57 +00:00
@property
2018-04-16 11:49:38 +00:00
def available_stock ( self ) :
"""
Return the total available stock .
2019-05-02 10:18:34 +00:00
- This subtracts stock which is already allocated to builds
2018-04-16 11:49:38 +00:00
"""
2018-04-17 12:26:57 +00:00
total = self . total_stock
2020-04-28 00:35:19 +00:00
total - = self . allocation_count ( )
2018-04-17 12:26:57 +00:00
2019-05-23 11:51:27 +00:00
return max ( total , 0 )
2019-05-02 10:18:34 +00:00
2021-02-16 23:27:36 +00:00
def requiring_build_orders ( self ) :
"""
Return list of outstanding build orders which require this part
"""
2021-02-18 03:35:21 +00:00
# List parts that this part is required for
parts = self . get_used_in ( ) . all ( )
2021-02-16 23:27:36 +00:00
2021-02-18 03:35:21 +00:00
part_ids = [ part . pk for part in parts ]
2021-02-16 23:27:36 +00:00
# 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
"""
2021-02-18 03:35:21 +00:00
# List active build orders which reference this part
builds = self . requiring_build_orders ( )
2021-02-16 23:27:36 +00:00
quantity = 0
for build in builds :
2021-05-06 10:11:38 +00:00
2021-02-16 23:27:36 +00:00
bom_item = None
2021-02-18 03:35:21 +00:00
# List the bom lines required to make the build (including inherited ones!)
bom_items = build . part . get_bom_items ( ) . filter ( sub_part = self )
2021-02-16 23:27:36 +00:00
2021-02-18 03:35:21 +00:00
# Match BOM item to build
for bom_item in bom_items :
2021-02-16 23:27:36 +00:00
2021-02-18 03:35:21 +00:00
build_quantity = build . quantity * bom_item . quantity
2021-02-16 23:27:36 +00:00
2021-02-18 03:35:21 +00:00
quantity + = build_quantity
2021-05-06 10:11:38 +00:00
2021-02-16 23:27:36 +00:00
return quantity
2021-02-16 23:57:17 +00:00
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
2021-02-17 02:06:18 +00:00
def required_order_quantity ( self ) :
"""
Return total required to fulfil orders
"""
return self . required_build_order_quantity ( ) + self . required_sales_order_quantity ( )
2019-06-11 13:37:32 +00:00
@property
def quantity_to_order ( self ) :
2021-02-17 02:06:18 +00:00
"""
Return the quantity needing to be ordered for this part .
2021-05-06 10:11:38 +00:00
2021-02-17 02:06:18 +00:00
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
2021-05-06 10:11:38 +00:00
2021-02-17 02:06:18 +00:00
"""
# Total requirement
required = self . required_order_quantity ( )
2019-06-11 13:37:32 +00:00
2021-02-17 02:06:18 +00:00
# Subtract stock levels
required - = max ( self . total_stock , self . minimum_stock )
2020-06-28 09:14:51 +00:00
2021-02-17 02:06:18 +00:00
# Subtract quantity on order
required - = self . on_order
2020-06-28 09:14:51 +00:00
2021-02-17 02:06:18 +00:00
# Subtract quantity being built
required - = self . quantity_being_built
2020-06-28 09:14:51 +00:00
2019-06-11 13:37:32 +00:00
return max ( required , 0 )
2019-06-11 11:58:20 +00:00
@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 .
"""
2020-05-02 10:10:12 +00:00
return self . total_stock - self . allocation_count ( ) + self . on_order
2019-06-11 11:58:20 +00:00
2019-05-05 00:54:21 +00:00
def isStarredBy ( self , user ) :
""" Return True if this part has been starred by a particular user """
try :
PartStar . objects . get ( part = self , user = user )
return True
except PartStar . DoesNotExist :
return False
2021-02-25 12:27:27 +00:00
def setStarred ( self , user , starred ) :
"""
Set the " starred " status of this Part for the given user
"""
if not user :
return
# Do not duplicate efforts
if self . isStarredBy ( user ) == starred :
return
if starred :
PartStar . objects . create ( part = self , user = user )
else :
PartStar . objects . filter ( part = self , user = user ) . delete ( )
2019-05-02 10:18:34 +00:00
def need_to_restock ( self ) :
""" Return True if this part needs to be restocked
( either by purchasing or building ) .
2019-05-02 10:19:08 +00:00
If the allocated_stock exceeds the total_stock ,
2019-05-02 10:18:34 +00:00
then we need to restock .
"""
2019-06-10 13:05:14 +00:00
return ( self . total_stock + self . on_order - self . allocation_count ) < self . minimum_stock
2018-04-16 11:49:38 +00:00
@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 :
2019-05-09 08:35:55 +00:00
return 0
2018-04-16 11:49:38 +00:00
total = None
2021-02-17 11:53:56 +00:00
bom_items = self . get_bom_items ( ) . prefetch_related ( ' sub_part__stock_items ' )
2018-04-16 12:13:31 +00:00
# Calculate the minimum number of parts that can be built using each sub-part
2021-02-17 11:53:56 +00:00
for item in bom_items . all ( ) :
2018-04-16 11:49:38 +00:00
stock = item . sub_part . available_stock
2020-06-04 01:37:55 +00:00
# If (by some chance) we get here but the BOM item quantity is invalid,
# ignore!
if item . quantity < = 0 :
continue
2019-11-18 21:49:54 +00:00
n = int ( stock / item . quantity )
2018-04-16 11:49:38 +00:00
if total is None or n < total :
total = n
2020-06-04 01:37:55 +00:00
if total is None :
total = 0
2021-05-06 10:11:38 +00:00
2018-04-17 12:26:57 +00:00
return max ( total , 0 )
2018-04-15 14:30:57 +00:00
2018-04-17 10:25:43 +00:00
@property
def active_builds ( self ) :
""" Return a list of outstanding builds.
Builds marked as ' complete ' or ' cancelled ' are ignored
"""
2019-06-04 13:38:52 +00:00
return self . builds . filter ( status__in = BuildStatus . ACTIVE_CODES )
2018-04-17 10:25:43 +00:00
@property
def inactive_builds ( self ) :
""" Return a list of inactive builds
"""
2019-06-04 13:38:52 +00:00
return self . builds . exclude ( status__in = BuildStatus . ACTIVE_CODES )
2018-04-17 10:25:43 +00:00
@property
def quantity_being_built ( self ) :
"""
2021-02-17 02:14:27 +00:00
Return the current number of parts currently being built .
2018-04-17 10:25:43 +00:00
2021-02-17 02:14:27 +00:00
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
"""
2020-03-26 06:43:02 +00:00
2021-02-17 02:14:27 +00:00
builds = self . active_builds
quantity = 0
2020-03-26 06:43:02 +00:00
2021-02-17 02:14:27 +00:00
for build in builds :
# The remaining items in the build
quantity + = build . remaining
return quantity
2018-04-17 10:25:43 +00:00
2020-04-28 00:35:19 +00:00
def build_order_allocations ( self ) :
"""
Return all ' BuildItem ' objects which allocate this part to Build objects
2018-04-17 12:26:57 +00:00
"""
2020-04-28 00:35:19 +00:00
return BuildModels . BuildItem . objects . filter ( stock_item__part__id = self . id )
2018-04-30 12:45:11 +00:00
2020-04-28 00:35:19 +00:00
def build_order_allocation_count ( self ) :
"""
Return the total amount of this part allocated to build orders
"""
2018-04-30 12:45:11 +00:00
2021-04-20 00:59:28 +00:00
query = self . build_order_allocations ( ) . aggregate (
total = Coalesce (
Sum (
' quantity ' ,
output_field = models . DecimalField ( )
) ,
0 ,
output_field = models . DecimalField ( ) ,
)
)
2018-04-30 12:45:11 +00:00
2020-04-28 00:35:19 +00:00
return query [ ' total ' ]
2018-04-17 12:26:57 +00:00
2020-04-28 00:35:19 +00:00
def sales_order_allocations ( self ) :
"""
Return all sales - order - allocation objects which allocate this part to a SalesOrder
"""
2019-05-20 21:57:44 +00:00
2020-04-28 00:35:19 +00:00
return OrderModels . SalesOrderAllocation . objects . filter ( item__part__id = self . id )
2018-04-17 12:26:57 +00:00
2020-04-28 00:35:19 +00:00
def sales_order_allocation_count ( self ) :
"""
Return the tutal quantity of this part allocated to sales orders
2018-04-17 12:26:57 +00:00
"""
2021-04-20 00:59:28 +00:00
query = self . sales_order_allocations ( ) . aggregate (
total = Coalesce (
Sum (
' quantity ' ,
output_field = models . DecimalField ( ) ,
) ,
0 ,
output_field = models . DecimalField ( ) ,
)
)
2018-04-17 12:26:57 +00:00
2020-04-28 00:35:19 +00:00
return query [ ' total ' ]
2019-04-27 12:18:07 +00:00
2020-04-28 00:35:19 +00:00
def allocation_count ( self ) :
"""
Return the total quantity of stock allocated for this part ,
against both build orders and sales orders .
2018-04-17 12:26:57 +00:00
"""
2021-04-19 23:14:08 +00:00
return sum (
[
self . build_order_allocation_count ( ) ,
self . sales_order_allocation_count ( ) ,
] ,
)
2018-04-17 12:26:57 +00:00
2020-05-15 22:43:57 +00:00
def stock_entries ( self , include_variants = True , in_stock = None ) :
""" Return all stock entries for this Part.
2019-05-20 11:47:30 +00:00
2020-05-15 22:43:57 +00:00
- 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 .
2019-05-20 11:47:30 +00:00
"""
2020-05-15 22:43:57 +00:00
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
2018-04-30 12:30:15 +00:00
2017-03-28 10:24:00 +00:00
@property
2018-04-16 12:13:31 +00:00
def total_stock ( self ) :
2017-03-28 10:24:00 +00:00
""" Return the total stock quantity for this part.
2021-05-06 10:11:38 +00:00
2020-05-15 22:43:57 +00:00
- Part may be stored in multiple locations
- If this part is a " template " ( variants exist ) then these are counted too
2017-03-28 10:24:00 +00:00
"""
2017-03-29 12:19:53 +00:00
2020-05-15 22:43:57 +00:00
entries = self . stock_entries ( in_stock = True )
2019-05-20 11:47:30 +00:00
2020-05-15 22:43:57 +00:00
query = entries . aggregate ( t = Coalesce ( Sum ( ' quantity ' ) , Decimal ( 0 ) ) )
return query [ ' t ' ]
2017-03-29 12:19:53 +00:00
2021-02-17 11:53:56 +00:00
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 ) )
2021-02-18 03:35:21 +00:00
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 ) )
2018-04-13 12:36:59 +00:00
@property
2018-04-16 11:49:38 +00:00
def has_bom ( self ) :
2021-02-17 11:53:56 +00:00
return self . get_bom_items ( ) . count ( ) > 0
2018-04-16 11:49:38 +00:00
2020-11-02 11:56:26 +00:00
@property
2020-11-01 14:24:31 +00:00
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 .
"""
2021-02-17 11:53:56 +00:00
for bom_item in self . get_bom_items ( ) . all ( ) :
2020-11-01 14:24:31 +00:00
if bom_item . sub_part . trackable :
return True
return False
2018-04-16 11:49:38 +00:00
@property
2018-04-16 12:13:31 +00:00
def bom_count ( self ) :
2019-05-12 06:27:50 +00:00
""" Return the number of items contained in the BOM for this part """
2021-02-17 11:53:56 +00:00
return self . get_bom_items ( ) . count ( )
2018-04-13 12:36:59 +00:00
@property
2018-04-16 11:49:38 +00:00
def used_in_count ( self ) :
2019-05-12 06:27:50 +00:00
""" Return the number of part BOMs that this part appears in """
2021-02-18 03:35:21 +00:00
return self . get_used_in ( ) . count ( )
2018-04-13 12:36:59 +00:00
2019-05-12 02:42:06 +00:00
def get_bom_hash ( self ) :
2019-05-12 06:27:50 +00:00
""" Return a checksum hash for the BOM for this part.
2019-05-12 02:42:06 +00:00
Used to determine if the BOM has changed ( and needs to be signed off ! )
2019-09-05 03:10:26 +00:00
The hash is calculated by hashing each line item in the BOM .
2019-05-12 02:42:06 +00:00
returns a string representation of a hash object which can be compared with a stored value
"""
2019-05-20 12:53:01 +00:00
hash = hashlib . md5 ( str ( self . id ) . encode ( ) )
2019-05-12 02:42:06 +00:00
2021-02-17 11:53:56 +00:00
# List *all* BOM items (including inherited ones!)
bom_items = self . get_bom_items ( ) . all ( ) . prefetch_related ( ' sub_part ' )
for item in bom_items :
2019-09-05 03:10:26 +00:00
hash . update ( str ( item . get_item_hash ( ) ) . encode ( ) )
2019-05-12 02:42:06 +00:00
return str ( hash . digest ( ) )
2019-05-12 02:53:56 +00:00
def is_bom_valid ( self ) :
""" Check if the BOM is ' valid ' - if the calculated checksum matches the stored value
"""
2021-03-15 14:36:12 +00:00
return self . get_bom_hash ( ) == self . bom_checksum or not self . has_bom
2019-05-12 02:53:56 +00:00
@transaction.atomic
2019-05-12 03:01:41 +00:00
def validate_bom ( self , user ) :
2019-05-12 03:12:04 +00:00
""" Validate the BOM (mark the BOM as validated by the given User.
2019-05-12 02:53:56 +00:00
- Calculates and stores the hash for the BOM
- Saves the current date and the checking user
"""
2021-02-17 11:53:56 +00:00
# Validate each line item, ignoring inherited ones
bom_items = self . get_bom_items ( include_inherited = False )
for item in bom_items . all ( ) :
2019-09-05 03:10:26 +00:00
item . validate_hash ( )
2019-05-12 06:27:50 +00:00
self . bom_checksum = self . get_bom_hash ( )
2019-05-12 02:53:56 +00:00
self . bom_checked_by = user
self . bom_checked_date = datetime . now ( ) . date ( )
self . save ( )
2019-07-10 02:27:19 +00:00
@transaction.atomic
def clear_bom ( self ) :
2021-02-17 11:53:56 +00:00
"""
Clear the BOM items for the part ( delete all BOM lines ) .
Note : Does * NOT * delete inherited BOM items !
2019-07-10 02:27:19 +00:00
"""
self . bom_items . all ( ) . delete ( )
2021-01-02 23:07:38 +00:00
def getRequiredParts ( self , recursive = False , parts = None ) :
2020-10-23 11:49:46 +00:00
"""
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 )
"""
2021-01-02 23:07:38 +00:00
if parts is None :
parts = set ( )
2021-02-17 11:53:56 +00:00
bom_items = self . get_bom_items ( ) . all ( )
2020-11-23 23:18:07 +00:00
2021-02-17 11:53:56 +00:00
for bom_item in bom_items :
2020-10-23 11:49:46 +00:00
sub_part = bom_item . sub_part
2020-11-23 23:18:07 +00:00
2020-10-23 11:49:46 +00:00
if sub_part not in parts :
parts . add ( sub_part )
if recursive :
sub_part . getRequiredParts ( recursive = True , parts = parts )
2020-10-22 12:28:15 +00:00
2019-05-02 08:53:03 +00:00
return parts
2019-07-07 01:22:01 +00:00
def get_allowed_bom_items ( self ) :
2020-10-23 13:14:27 +00:00
"""
Return a list of parts which can be added to a BOM for this part .
2019-07-07 01:22:01 +00:00
- Exclude parts which are not ' component ' parts
- Exclude parts which this part is in the BOM for
"""
2020-11-11 05:09:14 +00:00
# Start with a list of all parts designated as 'sub components'
parts = Part . objects . filter ( component = True )
2021-05-06 10:11:38 +00:00
2020-11-11 05:09:14 +00:00
# Exclude this part
parts = parts . exclude ( id = self . id )
# Exclude any parts that this part is used *in* (to prevent recursive BOMs)
2021-02-18 03:35:21 +00:00
used_in = self . get_used_in ( ) . all ( )
2020-11-11 05:09:14 +00:00
parts = parts . exclude ( id__in = [ item . part . id for item in used_in ] )
2019-07-07 01:22:01 +00:00
return parts
2018-04-16 12:13:31 +00:00
@property
def supplier_count ( self ) :
2019-05-12 02:53:56 +00:00
""" Return the number of supplier parts available for this part """
2018-04-16 13:26:02 +00:00
return self . supplier_parts . count ( )
2018-04-16 12:13:31 +00:00
2019-05-18 11:22:56 +00:00
@property
def has_pricing_info ( self ) :
""" Return true if there is pricing information for this part """
2019-05-20 13:53:39 +00:00
return self . get_price_range ( ) is not None
2019-05-18 11:22:56 +00:00
@property
def has_complete_bom_pricing ( self ) :
""" Return true if there is pricing information for each item in the BOM. """
2021-02-17 11:53:56 +00:00
for item in self . get_bom_items ( ) . all ( ) . select_related ( ' sub_part ' ) :
2019-05-18 11:22:56 +00:00
if not item . sub_part . has_pricing_info :
return False
return True
2019-05-18 11:56:00 +00:00
def get_price_info ( self , quantity = 1 , buy = True , bom = True ) :
""" Return a simplified pricing string for this part
2021-05-06 10:11:38 +00:00
2019-05-18 11:56:00 +00:00
Args :
quantity : Number of units to calculate price for
buy : Include supplier pricing ( default = True )
bom : Include BOM pricing ( default = True )
"""
2019-05-18 11:22:56 +00:00
2019-05-20 13:53:39 +00:00
price_range = self . get_price_range ( quantity , buy , bom )
if price_range is None :
2019-05-18 11:22:56 +00:00
return None
2019-05-20 13:53:39 +00:00
min_price , max_price = price_range
2019-05-18 11:22:56 +00:00
if min_price == max_price :
return min_price
2020-04-11 14:56:15 +00:00
min_price = normalize ( min_price )
max_price = normalize ( max_price )
2020-04-11 14:31:59 +00:00
2019-05-20 14:06:57 +00:00
return " {a} - {b} " . format ( a = min_price , b = max_price )
2019-05-18 11:22:56 +00:00
2019-05-20 13:53:39 +00:00
def get_supplier_price_range ( self , quantity = 1 ) :
2021-05-06 10:11:38 +00:00
2019-05-18 10:09:41 +00:00
min_price = None
2019-05-20 13:53:39 +00:00
max_price = None
2019-05-18 10:09:41 +00:00
2019-05-20 13:53:39 +00:00
for supplier in self . supplier_parts . all ( ) :
2019-05-18 10:09:41 +00:00
2019-05-20 13:53:39 +00:00
price = supplier . get_price ( quantity )
2019-05-18 10:09:41 +00:00
2019-05-20 13:53:39 +00:00
if price is None :
continue
2019-05-18 10:09:41 +00:00
2019-05-20 13:53:39 +00:00
if min_price is None or price < min_price :
min_price = price
2019-05-18 10:09:41 +00:00
2019-05-20 13:53:39 +00:00
if max_price is None or price > max_price :
max_price = price
2019-05-18 10:09:41 +00:00
2019-05-20 13:53:39 +00:00
if min_price is None or max_price is None :
return None
2019-05-18 10:09:41 +00:00
2020-04-11 14:56:15 +00:00
min_price = normalize ( min_price )
max_price = normalize ( max_price )
2020-04-11 14:31:59 +00:00
2019-05-20 13:53:39 +00:00
return ( min_price , max_price )
2019-05-18 10:09:41 +00:00
2019-05-20 13:53:39 +00:00
def get_bom_price_range ( self , quantity = 1 ) :
""" Return the price range of the BOM for this part.
2019-05-18 11:22:56 +00:00
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
2019-05-20 13:53:39 +00:00
max_price = None
2019-05-18 11:22:56 +00:00
2021-02-17 12:57:45 +00:00
for item in self . get_bom_items ( ) . all ( ) . select_related ( ' sub_part ' ) :
2020-04-21 11:38:22 +00:00
if item . sub_part . pk == self . pk :
print ( " Warning: Item contains itself in BOM " )
continue
2019-05-20 13:53:39 +00:00
prices = item . sub_part . get_price_range ( quantity * item . quantity )
2019-05-18 11:22:56 +00:00
2019-05-20 13:53:39 +00:00
if prices is None :
2019-05-18 11:22:56 +00:00
continue
2019-05-20 13:53:39 +00:00
low , high = prices
2019-05-18 11:22:56 +00:00
if min_price is None :
min_price = 0
if max_price is None :
max_price = 0
2019-05-20 13:53:39 +00:00
min_price + = low
max_price + = high
if min_price is None or max_price is None :
return None
2019-05-18 11:22:56 +00:00
2020-04-11 14:56:15 +00:00
min_price = normalize ( min_price )
max_price = normalize ( max_price )
2020-04-11 14:31:59 +00:00
2019-05-20 13:53:39 +00:00
return ( min_price , max_price )
2019-05-18 10:09:41 +00:00
2019-05-20 13:53:39 +00:00
def get_price_range ( self , quantity = 1 , buy = True , bom = True ) :
2021-05-06 10:11:38 +00:00
2019-05-20 13:53:39 +00:00
""" Return the price range for this part. This price can be either:
2019-05-18 11:22:56 +00:00
- Supplier price ( if purchased from suppliers )
- BOM price ( if built from other parts )
Returns :
Minimum of the supplier price or BOM price . If no pricing available , returns None
"""
2019-05-20 13:53:39 +00:00
buy_price_range = self . get_supplier_price_range ( quantity ) if buy else None
bom_price_range = self . get_bom_price_range ( quantity ) if bom else None
2019-05-18 11:22:56 +00:00
2019-05-20 13:53:39 +00:00
if buy_price_range is None :
return bom_price_range
2019-05-18 11:22:56 +00:00
2019-05-20 13:53:39 +00:00
elif bom_price_range is None :
return buy_price_range
2019-05-18 11:22:56 +00:00
2019-05-20 13:53:39 +00:00
else :
return (
min ( buy_price_range [ 0 ] , bom_price_range [ 0 ] ) ,
max ( buy_price_range [ 1 ] , bom_price_range [ 1 ] )
2019-05-20 14:54:48 +00:00
)
2019-05-18 11:22:56 +00:00
2021-05-05 21:47:46 +00:00
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
)
2020-10-29 23:08:06 +00:00
@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
2021-02-17 11:53:56 +00:00
# Note: Inherited BOM items are *not* deleted!
2020-10-29 23:08:06 +00:00
self . bom_items . all ( ) . delete ( )
2021-02-17 11:53:56 +00:00
# Copy existing BOM items from another part
# Note: Inherited BOM Items will *not* be duplicated!!
2021-02-17 13:38:40 +00:00
for bom_item in other . get_bom_items ( include_inherited = False ) . all ( ) :
2020-10-29 23:08:06 +00:00
# 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 ( )
2020-10-30 05:54:05 +00:00
@transaction.atomic
def copy_parameters_from ( self , other , * * kwargs ) :
2021-05-06 10:11:38 +00:00
2020-10-30 05:54:05 +00:00
clear = kwargs . get ( ' clear ' , True )
if clear :
self . get_parameters ( ) . delete ( )
2020-10-30 10:34:56 +00:00
for parameter in other . get_parameters ( ) :
2020-10-30 05:54:05 +00:00
# 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
2020-11-01 14:24:31 +00:00
def deep_copy ( self , other , * * kwargs ) :
2019-05-13 11:54:52 +00:00
""" 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 )
2020-09-04 19:02:12 +00:00
parameters : If True , copies Parameters data ( default = True )
2019-05-13 11:41:32 +00:00
"""
2019-05-13 11:54:52 +00:00
# Copy the part image
if kwargs . get ( ' image ' , True ) :
2019-05-14 23:21:31 +00:00
if other . image :
2020-02-10 12:04:58 +00:00
# Reference the other image from this Part
self . image = other . image
2019-05-13 11:54:52 +00:00
# Copy the BOM data
if kwargs . get ( ' bom ' , False ) :
2020-10-29 23:08:06 +00:00
self . copy_bom_from ( other )
2019-05-13 11:54:52 +00:00
2020-09-04 19:02:12 +00:00
# Copy the parameters data
if kwargs . get ( ' parameters ' , True ) :
2020-10-30 05:54:05 +00:00
self . copy_parameters_from ( other )
2021-05-06 10:11:38 +00:00
2019-06-20 11:46:16 +00:00
# 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
2019-05-13 11:54:52 +00:00
self . save ( )
2019-05-13 11:41:32 +00:00
2020-05-17 03:46:19 +00:00
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
2021-05-06 10:11:38 +00:00
2020-05-17 12:03:55 +00:00
def getRequiredTests ( self ) :
# Return the tests which are required by this part
return self . getTestTemplates ( required = True )
2020-05-17 03:46:19 +00:00
2020-05-23 04:28:25 +00:00
def requiredTestCount ( self ) :
return self . getRequiredTests ( ) . count ( )
2019-05-25 14:39:36 +00:00
@property
def attachment_count ( self ) :
""" Count the number of attachments for this part.
2019-05-25 14:42:40 +00:00
If the part is a variant of a template part ,
2019-05-25 14:39:36 +00:00
include the number of attachments for the template part .
"""
2020-08-31 12:30:38 +00:00
return self . part_attachments . count ( )
2019-05-25 14:39:36 +00:00
2020-08-31 12:26:46 +00:00
@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
2020-04-20 23:15:01 +00:00
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
2019-06-05 11:47:22 +00:00
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
2019-06-10 12:31:19 +00:00
def open_purchase_orders ( self ) :
""" Return a list of open purchase orders against this part """
2020-04-23 10:38:09 +00:00
return [ order for order in self . purchase_orders ( ) if order . status in PurchaseOrderStatus . OPEN ]
2019-06-10 12:31:19 +00:00
def closed_purchase_orders ( self ) :
""" Return a list of closed purchase orders against this part """
2020-04-23 10:38:09 +00:00
return [ order for order in self . purchase_orders ( ) if order . status not in PurchaseOrderStatus . OPEN ]
2019-06-10 12:31:19 +00:00
2019-06-10 13:05:14 +00:00
@property
2019-06-06 22:37:25 +00:00
def on_order ( self ) :
""" Return the total number of items on order for this part. """
2020-04-23 10:38:09 +00:00
orders = self . supplier_parts . filter ( purchase_order_line_items__order__status__in = PurchaseOrderStatus . OPEN ) . aggregate (
2020-03-26 06:56:44 +00:00
quantity = Sum ( ' purchase_order_line_items__quantity ' ) ,
received = Sum ( ' purchase_order_line_items__received ' )
)
2020-03-26 06:31:59 +00:00
quantity = orders [ ' quantity ' ]
2020-03-26 06:56:44 +00:00
received = orders [ ' received ' ]
2020-03-26 06:31:59 +00:00
if quantity is None :
quantity = 0
2020-03-26 06:56:44 +00:00
if received is None :
received = 0
return quantity - received
2019-06-06 22:37:25 +00:00
2019-08-20 02:44:00 +00:00
def get_parameters ( self ) :
""" Return all parameters for this part, ordered by name """
2019-08-20 03:03:36 +00:00
return self . parameters . order_by ( ' template__name ' )
2019-08-20 02:44:00 +00:00
2020-05-25 03:13:28 +00:00
@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 )
2021-05-27 02:35:55 +00:00
@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
2020-10-16 18:50:31 +00:00
def get_related_parts ( self ) :
""" Return list of tuples for all related parts:
- first value is PartRelated object
- second value is matching Part object
"""
2020-10-15 19:11:24 +00:00
related_parts = [ ]
2020-10-16 18:50:31 +00:00
related_parts_1 = self . related_parts_1 . filter ( part_1__id = self . pk )
2020-10-15 19:11:24 +00:00
2020-10-16 18:50:31 +00:00
related_parts_2 = self . related_parts_2 . filter ( part_2__id = self . pk )
2020-10-15 19:11:24 +00:00
2020-10-16 18:50:31 +00:00
for related_part in related_parts_1 :
# Add to related parts list
related_parts . append ( ( related_part , related_part . part_2 ) )
2020-10-15 19:11:24 +00:00
2020-10-16 18:50:31 +00:00
for related_part in related_parts_2 :
# Add to related parts list
related_parts . append ( ( related_part , related_part . part_1 ) )
2020-10-15 19:11:24 +00:00
return related_parts
2020-10-16 18:50:31 +00:00
@property
def related_count ( self ) :
return len ( self . get_related_parts ( ) )
2018-04-15 15:02:17 +00:00
2018-04-14 08:44:56 +00:00
def attach_file ( instance , filename ) :
2019-04-27 12:18:07 +00:00
""" Function for storing a file for a PartAttachment
Args :
instance : Instance of a PartAttachment object
filename : name of uploaded file
Returns :
path to store file , format : ' part_file_<pk>_filename '
"""
2019-04-17 14:14:53 +00:00
# Construct a path to store a file attachment
return os . path . join ( ' part_files ' , str ( instance . part . id ) , filename )
2018-04-14 08:44:56 +00:00
2018-04-15 15:02:17 +00:00
2020-03-22 06:59:23 +00:00
class PartAttachment ( InvenTreeAttachment ) :
2020-03-22 07:13:34 +00:00
"""
Model for storing file attachments against a Part object
"""
2021-05-06 10:11:38 +00:00
2020-03-22 06:59:23 +00:00
def getSubdir ( self ) :
return os . path . join ( " part_files " , str ( self . part . id ) )
2018-04-14 08:44:56 +00:00
part = models . ForeignKey ( Part , on_delete = models . CASCADE ,
2021-04-04 20:44:14 +00:00
verbose_name = _ ( ' Part ' ) , related_name = ' attachments ' )
2018-04-14 08:44:56 +00:00
2020-09-17 13:22:37 +00:00
class PartSellPriceBreak ( common . models . PriceBreak ) :
"""
Represents a price break for selling this part
"""
part = models . ForeignKey (
Part , on_delete = models . CASCADE ,
2020-09-17 23:16:59 +00:00
related_name = ' salepricebreaks ' ,
2021-04-04 20:44:14 +00:00
limit_choices_to = { ' salable ' : True } ,
verbose_name = _ ( ' Part ' )
2020-09-17 13:22:37 +00:00
)
class Meta :
unique_together = ( ' part ' , ' quantity ' )
2019-05-04 22:46:23 +00:00
class PartStar ( models . Model ) :
""" A PartStar object creates a relationship between a User and a Part.
It is used to designate a Part as ' starred ' ( or favourited ) for a given User ,
so that the user can track a list of their favourite parts .
2019-05-10 10:11:52 +00:00
Attributes :
part : Link to a Part object
user : Link to a User object
2019-05-04 22:46:23 +00:00
"""
2021-04-04 20:44:14 +00:00
part = models . ForeignKey ( Part , on_delete = models . CASCADE , verbose_name = _ ( ' Part ' ) , related_name = ' starred_users ' )
2019-05-04 22:46:23 +00:00
2021-04-04 20:44:14 +00:00
user = models . ForeignKey ( User , on_delete = models . CASCADE , verbose_name = _ ( ' User ' ) , related_name = ' starred_parts ' )
2019-05-04 22:46:23 +00:00
2019-05-04 22:48:41 +00:00
class Meta :
unique_together = [ ' part ' , ' user ' ]
2019-05-04 22:46:23 +00:00
2020-05-17 03:26:51 +00:00
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 ) .
"""
2020-05-17 03:46:19 +00:00
def save ( self , * args , * * kwargs ) :
2020-05-17 03:26:51 +00:00
self . clean ( )
2020-05-17 03:46:19 +00:00
super ( ) . save ( * args , * * kwargs )
2020-05-17 03:26:51 +00:00
2020-05-17 04:14:54 +00:00
def clean ( self ) :
self . test_name = self . test_name . strip ( )
self . validate_unique ( )
super ( ) . clean ( )
2020-05-17 03:26:51 +00:00
def validate_unique ( self , exclude = None ) :
"""
Test that this test template is ' unique ' within this part tree .
"""
2020-05-17 04:14:54 +00:00
if not self . part . trackable :
raise ValidationError ( {
' part ' : _ ( ' Test templates can only be created for trackable parts ' )
} )
2020-05-17 03:26:51 +00:00
# Get a list of all tests "above" this one
2020-05-17 03:46:19 +00:00
tests = PartTestTemplate . objects . filter (
2020-05-17 03:26:51 +00:00
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 " )
} )
2020-05-17 04:14:54 +00:00
super ( ) . validate_unique ( exclude )
2020-05-17 03:26:51 +00:00
@property
def key ( self ) :
""" Generate a key for this test """
return helpers . generateTestKey ( self . test_name )
part = models . ForeignKey (
Part ,
on_delete = models . CASCADE ,
2020-05-17 03:50:06 +00:00
related_name = ' test_templates ' ,
limit_choices_to = { ' trackable ' : True } ,
2021-04-04 20:44:14 +00:00
verbose_name = _ ( ' Part ' ) ,
2020-05-17 03:26:51 +00:00
)
test_name = models . CharField (
blank = False , max_length = 100 ,
2020-05-18 09:00:45 +00:00
verbose_name = _ ( " Test Name " ) ,
2020-05-17 03:26:51 +00:00
help_text = _ ( " Enter a name for the test " )
)
2020-05-18 09:00:45 +00:00
description = models . CharField (
blank = False , null = True , max_length = 100 ,
verbose_name = _ ( " Test Description " ) ,
help_text = _ ( " Enter description for this test " )
)
2020-05-17 03:26:51 +00:00
required = models . BooleanField (
default = True ,
verbose_name = _ ( " Required " ) ,
help_text = _ ( " Is this test required to pass? " )
)
2020-05-18 09:00:45 +00:00
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? " )
)
2020-05-17 03:26:51 +00:00
2019-08-20 03:02:00 +00:00
class PartParameterTemplate ( models . Model ) :
2019-08-28 09:44:02 +00:00
"""
A PartParameterTemplate provides a template for key : value pairs for extra
2019-08-20 02:31:43 +00:00
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 ]
2019-08-20 03:02:00 +00:00
units : The units of the Parameter [ string ]
"""
def __str__ ( self ) :
s = str ( self . name )
if self . units :
s + = " ( {units} ) " . format ( units = self . units )
return s
2019-08-20 03:08:06 +00:00
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 :
2019-09-08 10:18:21 +00:00
others = PartParameterTemplate . objects . filter ( name__iexact = self . name ) . exclude ( pk = self . pk )
2019-08-20 03:08:06 +00:00
if others . exists ( ) :
msg = _ ( " Parameter template name must be unique " )
raise ValidationError ( { " name " : msg } )
except PartParameterTemplate . DoesNotExist :
pass
2019-08-20 03:02:00 +00:00
2021-04-04 20:44:14 +00:00
name = models . CharField ( max_length = 100 , verbose_name = _ ( ' Name ' ) , help_text = _ ( ' Parameter Name ' ) , unique = True )
2019-08-20 03:02:00 +00:00
2021-04-04 20:44:14 +00:00
units = models . CharField ( max_length = 25 , verbose_name = _ ( ' Units ' ) , help_text = _ ( ' Parameter Units ' ) , blank = True )
2019-08-20 03:02:00 +00:00
class PartParameter ( models . Model ) :
2019-08-28 09:44:02 +00:00
"""
A PartParameter is a specific instance of a PartParameterTemplate . It assigns a particular parameter < key : value > pair to a part .
2019-08-20 03:02:00 +00:00
Attributes :
part : Reference to a single Part object
template : Reference to a single PartParameterTemplate object
2019-08-20 02:31:43 +00:00
data : The data ( value ) of the Parameter [ string ]
"""
2019-08-20 03:02:00 +00:00
def __str__ ( self ) :
2019-08-28 09:44:46 +00:00
# String representation of a PartParameter (used in the admin interface)
2019-08-20 03:02:00 +00:00
return " {part} : {param} = {data} {units} " . format (
2019-09-08 10:18:21 +00:00
part = str ( self . part . full_name ) ,
2019-08-20 03:02:00 +00:00
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 ' )
2021-04-03 01:59:09 +00:00
part = models . ForeignKey ( Part , on_delete = models . CASCADE , related_name = ' parameters ' , verbose_name = _ ( ' Part ' ) , help_text = _ ( ' Parent Part ' ) )
2019-08-20 02:31:43 +00:00
2021-04-03 01:59:09 +00:00
template = models . ForeignKey ( PartParameterTemplate , on_delete = models . CASCADE , related_name = ' instances ' , verbose_name = _ ( ' Template ' ) , help_text = _ ( ' Parameter Template ' ) )
2019-08-20 02:31:43 +00:00
2021-04-03 01:59:09 +00:00
data = models . CharField ( max_length = 500 , verbose_name = _ ( ' Data ' ) , help_text = _ ( ' Parameter Value ' ) )
2019-08-20 02:31:43 +00:00
2020-09-04 19:02:12 +00:00
@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
2019-08-20 02:31:43 +00:00
2020-10-30 21:09:27 +00:00
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) """
2020-11-04 17:06:07 +00:00
2020-10-30 21:09:27 +00:00
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 ' ,
2021-04-04 20:44:14 +00:00
verbose_name = _ ( ' Category ' ) ,
2020-10-30 21:09:27 +00:00
help_text = _ ( ' Part Category ' ) )
parameter_template = models . ForeignKey ( PartParameterTemplate ,
on_delete = models . CASCADE ,
related_name = ' part_categories ' ,
2021-04-04 20:44:14 +00:00
verbose_name = _ ( ' Parameter Template ' ) ,
2020-10-30 21:09:27 +00:00
help_text = _ ( ' Parameter Template ' ) )
default_value = models . CharField ( max_length = 500 ,
blank = True ,
2021-04-04 20:44:14 +00:00
verbose_name = _ ( ' Default Value ' ) ,
2020-10-30 21:09:27 +00:00
help_text = _ ( ' Default Parameter Value ' ) )
2018-04-14 04:19:03 +00:00
class BomItem ( models . Model ) :
""" A BomItem links a part to its component items.
A part can have a BOM ( bill of materials ) which defines
2020-07-05 21:25:05 +00:00
which parts are required ( and in what quantity ) to make it .
2019-05-10 10:11:52 +00:00
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 '
2020-10-04 13:42:09 +00:00
optional : Boolean field describing if this BomItem is optional
2019-06-27 11:44:40 +00:00
reference : BOM reference field ( e . g . part designators )
2019-05-14 14:16:34 +00:00
overage : Estimated losses for a Build . Can be expressed as absolute value ( e . g . ' 7 ' ) or a percentage ( e . g . ' 2 % ' )
2019-05-10 10:11:52 +00:00
note : Note field for this BOM item
2019-09-05 02:58:11 +00:00
checksum : Validation checksum for the particular BOM line item
2021-02-17 10:53:15 +00:00
inherited : This BomItem can be inherited by the BOMs of variant parts
2021-06-01 03:59:01 +00:00
allow_variants : Stock for part variants can be substituted for this BomItem
2018-04-14 04:19:03 +00:00
"""
2020-05-24 10:22:15 +00:00
def save ( self , * args , * * kwargs ) :
self . clean ( )
super ( ) . save ( * args , * * kwargs )
2018-04-14 04:19:03 +00:00
# A link to the parent part
# Each part will get a reverse lookup field 'bom_items'
2018-04-17 13:39:53 +00:00
part = models . ForeignKey ( Part , on_delete = models . CASCADE , related_name = ' bom_items ' ,
2021-04-04 20:44:14 +00:00
verbose_name = _ ( ' Part ' ) ,
2019-11-18 21:42:10 +00:00
help_text = _ ( ' Select parent part ' ) ,
2019-05-05 12:34:00 +00:00
limit_choices_to = {
2019-06-02 09:46:30 +00:00
' assembly ' : True ,
2019-05-05 12:35:39 +00:00
} )
2018-04-14 04:19:03 +00:00
# A link to the child item (sub-part)
# Each part will get a reverse lookup field 'used_in'
2019-04-15 15:45:16 +00:00
sub_part = models . ForeignKey ( Part , on_delete = models . CASCADE , related_name = ' used_in ' ,
2021-04-04 20:44:14 +00:00
verbose_name = _ ( ' Sub part ' ) ,
2019-11-18 21:42:10 +00:00
help_text = _ ( ' Select part to be used in BOM ' ) ,
2019-05-05 12:34:00 +00:00
limit_choices_to = {
2019-06-02 09:46:30 +00:00
' component ' : True ,
2019-05-05 12:35:39 +00:00
} )
2018-04-14 04:19:03 +00:00
# Quantity required
2021-04-04 20:44:14 +00:00
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 ' ) )
2018-04-14 04:19:03 +00:00
2021-04-04 20:44:14 +00:00
optional = models . BooleanField ( default = False , verbose_name = _ ( ' Optional ' ) , help_text = _ ( " This BOM item is optional " ) )
2020-10-04 13:42:09 +00:00
2019-05-14 14:16:34 +00:00
overage = models . CharField ( max_length = 24 , blank = True , validators = [ validators . validate_overage ] ,
2021-04-06 16:33:57 +00:00
verbose_name = _ ( ' Overage ' ) ,
2019-11-18 21:42:10 +00:00
help_text = _ ( ' Estimated build wastage quantity (absolute or percentage) ' )
2019-05-14 14:16:34 +00:00
)
2021-04-04 20:44:14 +00:00
reference = models . CharField ( max_length = 500 , blank = True , verbose_name = _ ( ' Reference ' ) , help_text = _ ( ' BOM item reference ' ) )
2019-06-27 11:44:40 +00:00
2019-04-14 08:26:11 +00:00
# Note attached to this BOM line item
2021-04-04 20:44:14 +00:00
note = models . CharField ( max_length = 500 , blank = True , verbose_name = _ ( ' Note ' ) , help_text = _ ( ' BOM item notes ' ) )
2019-04-14 08:26:11 +00:00
2021-04-04 20:44:14 +00:00
checksum = models . CharField ( max_length = 128 , blank = True , verbose_name = _ ( ' Checksum ' ) , help_text = _ ( ' BOM line checksum ' ) )
2019-09-05 02:58:11 +00:00
2021-02-17 10:53:15 +00:00
inherited = models . BooleanField (
default = False ,
verbose_name = _ ( ' Inherited ' ) ,
help_text = _ ( ' This BOM item is inherited by BOMs for variant parts ' ) ,
)
2021-06-01 03:59:01 +00:00
allow_variants = models . BooleanField (
default = False ,
verbose_name = _ ( ' Allow Variants ' ) ,
help_text = _ ( ' Stock items for variant parts can be used for this BOM item ' )
)
2019-09-05 03:10:26 +00:00
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
2021-02-17 13:40:30 +00:00
- Optional field
- Inherited field
2019-09-05 03:10:26 +00:00
"""
# 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 ( ) )
2021-02-17 13:40:30 +00:00
hash . update ( str ( self . optional ) . encode ( ) )
hash . update ( str ( self . inherited ) . encode ( ) )
2019-09-05 03:10:26 +00:00
return str ( hash . digest ( ) )
2019-09-05 09:29:51 +00:00
def validate_hash ( self , valid = True ) :
""" Mark this item as ' valid ' (store the checksum hash).
2021-05-06 10:11:38 +00:00
2019-09-05 09:29:51 +00:00
Args :
valid : If true , validate the hash , otherwise invalidate it ( default = True )
"""
if valid :
self . checksum = str ( self . get_item_hash ( ) )
else :
self . checksum = ' '
2019-09-05 03:10:26 +00:00
self . save ( )
@property
def is_line_valid ( self ) :
""" Check if this line item has been validated by the user """
2019-09-05 09:29:51 +00:00
# Ensure an empty checksum returns False
if len ( self . checksum ) == 0 :
return False
2019-09-05 03:10:26 +00:00
return self . get_item_hash ( ) == self . checksum
2018-04-27 13:23:44 +00:00
def clean ( self ) :
2020-10-25 21:29:06 +00:00
"""
Check validity of the BomItem model .
2019-04-27 12:18:07 +00:00
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
2020-10-25 21:29:06 +00:00
- If the " sub_part " is trackable , then the " part " must be trackable too !
2019-04-27 12:18:07 +00:00
"""
2018-04-27 13:23:44 +00:00
2020-05-24 10:22:15 +00:00
try :
2020-11-23 22:33:26 +00:00
# Check for circular BOM references
if self . sub_part :
self . sub_part . checkAddToBOM ( self . part )
2021-05-06 10:11:38 +00:00
2020-11-23 22:33:26 +00:00
# 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 ' ) } )
2020-05-24 10:22:15 +00:00
except Part . DoesNotExist :
2020-11-24 00:56:51 +00:00
raise ValidationError ( { ' sub_part ' : _ ( ' Sub part must be specified ' ) } )
2018-04-27 13:23:44 +00:00
2018-04-14 04:19:03 +00:00
class Meta :
2020-08-08 06:54:09 +00:00
verbose_name = _ ( " BOM Item " )
2018-04-14 04:19:03 +00:00
# Prevent duplication of parent/child rows
unique_together = ( ' part ' , ' sub_part ' )
def __str__ ( self ) :
2019-05-04 14:00:30 +00:00
return " {n} x {child} to make {parent} " . format (
2019-05-12 02:16:04 +00:00
parent = self . part . full_name ,
child = self . sub_part . full_name ,
2019-11-18 21:59:56 +00:00
n = helpers . decimal2string ( self . quantity ) )
2018-04-22 11:54:12 +00:00
2020-04-28 13:17:15 +00:00
def available_stock ( self ) :
"""
Return the available stock items for the referenced sub_part
"""
2021-01-14 04:20:42 +00:00
query = self . sub_part . stock_items . all ( )
2021-05-06 10:11:38 +00:00
2021-01-14 04:20:42 +00:00
query = query . prefetch_related ( [
' sub_part__stock_items ' ,
] )
query = query . filter ( StockModels . StockItem . IN_STOCK_FILTER ) . aggregate (
2020-04-28 13:17:15 +00:00
available = Coalesce ( Sum ( ' quantity ' ) , 0 )
)
return query [ ' available ' ]
2019-05-14 14:36:02 +00:00
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 ( )
2020-03-18 23:15:43 +00:00
# Is the overage a numerical value?
2019-05-14 14:36:02 +00:00
try :
2020-03-18 23:15:43 +00:00
ovg = float ( overage )
2019-05-14 14:36:02 +00:00
if ovg < 0 :
2019-05-14 21:23:55 +00:00
ovg = 0
2019-05-14 14:36:02 +00:00
return ovg
except ValueError :
pass
# Is the overage a percentage?
if overage . endswith ( ' % ' ) :
overage = overage [ : - 1 ] . strip ( )
try :
percent = float ( overage ) / 100.0
2019-05-14 21:23:55 +00:00
if percent > 1 :
percent = 1
if percent < 0 :
percent = 0
2019-05-14 14:36:02 +00:00
2020-03-18 09:44:45 +00:00
# Must be represented as a decimal
percent = Decimal ( percent )
2020-03-18 23:15:43 +00:00
return float ( percent * quantity )
2019-05-14 14:36:02 +00:00
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 .
2021-05-06 10:11:38 +00:00
2019-05-14 14:36:02 +00:00
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
2020-03-18 23:15:43 +00:00
# Overage requiremet
ovrg_quantity = self . get_overage_quantity ( base_quantity )
required = float ( base_quantity ) + float ( ovrg_quantity )
return required
2019-05-21 05:15:54 +00:00
@property
def price_range ( self ) :
""" Return the price-range for this BOM item. """
prange = self . sub_part . get_price_range ( self . quantity )
if prange is None :
return prange
pmin , pmax = prange
if pmin == pmax :
2020-02-11 23:08:35 +00:00
return decimal2string ( pmin )
2019-05-21 05:15:54 +00:00
2020-02-11 23:06:17 +00:00
# Convert to better string representation
pmin = decimal2string ( pmin )
pmax = decimal2string ( pmax )
2019-05-21 05:38:22 +00:00
return " {pmin} to {pmax} " . format ( pmin = pmin , pmax = pmax )
2020-10-15 19:11:24 +00:00
class PartRelated ( models . Model ) :
""" Store and handle related parts (eg. mating connector, crimps, etc.) """
2020-10-16 20:29:58 +00:00
part_1 = models . ForeignKey ( Part , related_name = ' related_parts_1 ' ,
2021-04-04 20:44:14 +00:00
verbose_name = _ ( ' Part 1 ' ) , on_delete = models . DO_NOTHING )
2020-10-15 19:11:24 +00:00
2020-10-16 20:29:58 +00:00
part_2 = models . ForeignKey ( Part , related_name = ' related_parts_2 ' ,
on_delete = models . DO_NOTHING ,
2021-04-04 20:44:14 +00:00
verbose_name = _ ( ' Part 2 ' ) , help_text = _ ( ' Select Related Part ' ) )
2020-10-15 21:58:39 +00:00
def __str__ ( self ) :
2020-10-16 20:29:58 +00:00
return f ' { self . part_1 } <--> { self . part_2 } '
2020-10-15 19:11:24 +00:00
2020-10-16 20:29:58 +00:00
def validate ( self , part_1 , part_2 ) :
''' Validate that the two parts relationship is unique '''
2020-10-15 21:58:39 +00:00
validate = True
2020-10-15 19:11:24 +00:00
parts = Part . objects . all ( )
2020-10-15 21:58:39 +00:00
related_parts = PartRelated . objects . all ( )
2020-10-15 19:11:24 +00:00
2020-10-15 21:58:39 +00:00
# Check if part exist and there are not the same part
2020-10-16 20:29:58 +00:00
if ( part_1 in parts and part_2 in parts ) and ( part_1 . pk != part_2 . pk ) :
2020-10-15 21:58:39 +00:00
# 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
2020-10-16 20:29:58 +00:00
break
2020-10-15 21:58:39 +00:00
else :
validate = False
2020-10-16 20:29:58 +00:00
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 )