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
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
2019-05-20 11:47:30 +00:00
from django . db . models import Sum
2019-05-20 21:57:44 +00:00
from django . db . models import prefetch_related_objects
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
2019-09-08 09:19:39 +00:00
from mptt . models import TreeForeignKey
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
2019-06-10 12:31:19 +00:00
from InvenTree . status_codes import BuildStatus , StockStatus , OrderStatus
2019-06-04 13:38:52 +00:00
2019-05-18 08:04:25 +00:00
from company . models import SupplierPart
2017-03-28 12:17:56 +00:00
2018-04-23 11:18:35 +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 ,
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
)
2019-11-18 21:42:10 +00:00
default_keywords = models . CharField ( blank = True , max_length = 250 , 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 :
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
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 ,
' ratio ' : ratio
} )
matches = sorted ( matches , key = lambda item : item [ ' ratio ' ] , reverse = reverse )
return matches
2020-02-10 13:29:29 +00:00
@cleanup.ignore
2017-03-25 12:19:49 +00:00
class Part ( models . Model ) :
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
2019-05-25 13:58:31 +00:00
is_template : If True , this part is a ' template ' part and cannot be instantiated as a StockItem
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
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 :
verbose_name = " Part "
verbose_name_plural = " Parts "
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 .
"""
if self . pk :
previous = Part . objects . get ( pk = self . pk )
if previous . image and not self . image == previous . image :
# Are there any (other) parts which reference the image?
n_refs = Part . objects . filter ( image = previous . image ) . exclude ( pk = self . pk ) . count ( )
if n_refs == 0 :
previous . image . delete ( save = False )
super ( ) . save ( * args , * * kwargs )
2019-05-10 12:17:13 +00:00
def __str__ ( self ) :
2019-05-12 02:16:04 +00:00
return " {n} - {d} " . format ( n = self . full_name , d = self . description )
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 )
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 )
# 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 ) :
""" Perform cleaning operations for the Part model """
2019-05-25 13:58:31 +00:00
if self . is_template and self . variant_of is not None :
2019-05-25 12:43:47 +00:00
raise ValidationError ( {
2019-05-25 13:58:31 +00:00
' is_template ' : _ ( " Part cannot be a template part if it is a variant of another part " ) ,
2019-05-25 12:43:47 +00:00
' variant_of ' : _ ( " Part cannot be a variant of another part if it is already a template " ) ,
} )
2019-06-20 11:37:25 +00:00
name = models . CharField ( max_length = 100 , blank = False ,
2019-11-18 21:42:10 +00:00
help_text = _ ( ' Part name ' ) ,
2019-05-10 12:52:06 +00:00
validators = [ validators . validate_part_name ]
)
2019-05-10 12:17:13 +00:00
2019-11-18 21:42:10 +00:00
is_template = models . BooleanField ( default = False , help_text = _ ( ' Is this part a template part? ' ) )
2019-05-25 12:27:36 +00:00
variant_of = models . ForeignKey ( ' part.Part ' , related_name = ' variants ' ,
null = True , blank = True ,
limit_choices_to = {
2019-05-25 13:58:31 +00:00
' is_template ' : True ,
2019-05-25 12:27:36 +00:00
' active ' : True ,
} ,
on_delete = models . SET_NULL ,
2019-11-18 21:42:10 +00:00
help_text = _ ( ' Is this part a variant of another part? ' ) )
2019-05-25 12:27:36 +00:00
2019-11-18 21:42:10 +00:00
description = models . CharField ( max_length = 250 , blank = False , help_text = _ ( ' Part description ' ) )
2017-04-01 02:31:48 +00:00
2019-11-18 21:42:10 +00:00
keywords = models . CharField ( max_length = 250 , blank = True , help_text = _ ( ' Part keywords to improve visibility in search results ' ) )
2019-05-14 07:23:20 +00:00
2019-09-08 09:19:39 +00:00
category = TreeForeignKey ( PartCategory , related_name = ' parts ' ,
null = True , blank = True ,
on_delete = models . DO_NOTHING ,
2019-11-18 21:42:10 +00:00
help_text = _ ( ' Part category ' ) )
2017-04-01 02:31:48 +00:00
2020-02-03 10:09:24 +00:00
IPN = models . CharField ( max_length = 100 , blank = True , help_text = _ ( ' Internal Part Number ' ) , validators = [ validators . validate_part_ipn ] )
2019-05-10 10:11:52 +00:00
2019-11-18 21:42:10 +00:00
revision = models . CharField ( max_length = 100 , blank = True , help_text = _ ( ' Part revision or version number ' ) )
2019-06-20 11:46:16 +00:00
2020-04-06 01:16:39 +00:00
link = InvenTreeURLField ( blank = True , help_text = _ ( ' Link to extenal 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 ) } ,
2020-04-04 13:38:25 +00:00
delete_orphans = True ,
)
2018-04-14 07:44:22 +00:00
2019-09-08 09:19:39 +00:00
default_location = TreeForeignKey ( ' stock.StockLocation ' , on_delete = models . SET_NULL ,
blank = True , null = True ,
2019-11-18 21:42:10 +00:00
help_text = _ ( ' Where is this item normally stored? ' ) ,
2019-09-08 09:19:39 +00:00
related_name = ' default_parts ' )
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
2019-05-18 08:04:25 +00:00
default_supplier = models . ForeignKey ( SupplierPart ,
2018-04-17 08:23:24 +00:00
on_delete = models . SET_NULL ,
blank = True , null = True ,
2019-11-18 21:42:10 +00:00
help_text = _ ( ' Default supplier part ' ) ,
2018-04-17 08:23:24 +00:00
related_name = ' default_parts ' )
2019-11-18 21:42:10 +00:00
minimum_stock = models . PositiveIntegerField ( default = 0 , validators = [ MinValueValidator ( 0 ) ] , help_text = _ ( ' Minimum allowed stock level ' ) )
2017-04-01 02:31:48 +00:00
2019-11-18 23:17:20 +00:00
units = models . CharField ( max_length = 20 , default = " " , blank = True , help_text = _ ( ' Stock keeping units for this part ' ) )
2017-04-01 02:31:48 +00:00
2019-11-18 21:42:10 +00:00
assembly = models . BooleanField ( default = False , verbose_name = ' Assembly ' , help_text = _ ( ' Can this part be built from other parts? ' ) )
2018-04-16 12:13:31 +00:00
2019-11-18 21:42:10 +00:00
component = models . BooleanField ( default = True , verbose_name = ' Component ' , help_text = _ ( ' Can this part be used to build other parts? ' ) )
2019-04-15 14:01:15 +00:00
2019-11-18 21:42:10 +00:00
trackable = models . BooleanField ( default = False , help_text = _ ( ' Does this part have tracking for unique items? ' ) )
2017-03-29 12:19:53 +00:00
2019-11-18 21:42:10 +00:00
purchaseable = models . BooleanField ( default = True , help_text = _ ( ' Can this part be purchased from external suppliers? ' ) )
2018-04-15 14:30:57 +00:00
2019-11-18 21:42:10 +00:00
salable = models . BooleanField ( default = False , help_text = _ ( " Can this part be sold to customers? " ) )
2018-04-17 08:11:34 +00:00
2019-11-18 21:42:10 +00:00
active = models . BooleanField ( default = True , help_text = _ ( ' Is this part active? ' ) )
2019-04-28 13:00:38 +00:00
2019-11-18 21:42:10 +00:00
virtual = models . BooleanField ( default = False , help_text = _ ( ' Is this a virtual part, such as a software product or license? ' ) )
2019-06-18 08:34:07 +00:00
2020-02-23 09:02:33 +00:00
notes = MarkdownxField ( blank = True , help_text = _ ( ' Part notes - supports Markdown formatting ' ) )
2018-04-17 15:44:55 +00:00
2019-11-18 21:42:10 +00:00
bom_checksum = models . CharField ( max_length = 128 , blank = True , 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 ,
related_name = ' boms_checked ' )
bom_checked_date = models . DateField ( blank = True , null = True )
2020-03-18 10:50:18 +00:00
creation_date = models . DateField ( auto_now_add = True , editable = False , blank = True , null = True )
creation_user = models . ForeignKey ( User , on_delete = models . SET_NULL , blank = True , null = True , related_name = ' parts_created ' )
responsible = models . ForeignKey ( User , on_delete = models . SET_NULL , blank = True , null = True , related_name = ' parts_responible ' )
2019-05-02 10:57:53 +00:00
def format_barcode ( self ) :
""" Return a JSON string for formatting a barcode for this Part object """
return helpers . MakeBarcode (
" Part " ,
self . id ,
reverse ( ' api-part-detail ' , kwargs = { ' pk ' : self . id } ) ,
2019-05-04 13:35:52 +00:00
{
' name ' : self . name ,
}
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
total - = self . allocation_count
2019-05-23 11:51:27 +00:00
return max ( total , 0 )
2019-05-02 10:18:34 +00:00
2019-06-11 13:37:32 +00:00
@property
def quantity_to_order ( self ) :
""" Return the quantity needing to be ordered for this part. """
required = - 1 * self . net_stock
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 .
"""
return self . total_stock - self . allocation_count + self . on_order
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
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
2018-04-16 12:13:31 +00:00
# Calculate the minimum number of parts that can be built using each sub-part
2019-05-20 21:57:44 +00:00
for item in self . bom_items . all ( ) . prefetch_related ( ' sub_part__stock_items ' ) :
2018-04-16 11:49:38 +00:00
stock = item . sub_part . available_stock
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
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 ) :
""" Return the current number of parts currently being built
"""
2020-03-26 06:43:02 +00:00
quantity = self . active_builds . aggregate ( quantity = Sum ( ' quantity ' ) ) [ ' quantity ' ]
if quantity is None :
quantity = 0
return quantity
2018-04-17 10:25:43 +00:00
2018-04-17 12:26:57 +00:00
@property
2018-04-30 12:45:11 +00:00
def build_allocation ( self ) :
2018-04-17 12:26:57 +00:00
""" Return list of builds to which this part is allocated
"""
builds = [ ]
2019-05-20 21:57:44 +00:00
for item in self . used_in . all ( ) . prefetch_related ( ' part__builds ' ) :
2018-04-30 12:45:11 +00:00
2019-05-20 21:57:44 +00:00
active = item . part . active_builds
for build in active :
2018-04-30 12:45:11 +00:00
b = { }
b [ ' build ' ] = build
b [ ' quantity ' ] = item . quantity * build . quantity
builds . append ( b )
2018-04-17 12:26:57 +00:00
2019-05-20 21:57:44 +00:00
prefetch_related_objects ( builds , ' build_items ' )
2018-04-17 12:26:57 +00:00
return builds
@property
def allocated_build_count ( self ) :
2019-05-20 12:53:01 +00:00
""" Return the total number of this part that are allocated for builds
2018-04-17 12:26:57 +00:00
"""
2018-04-30 12:45:11 +00:00
return sum ( [ a [ ' quantity ' ] for a in self . build_allocation ] )
2018-04-17 12:26:57 +00:00
@property
def allocation_count ( self ) :
2019-04-27 12:18:07 +00:00
""" Return true if any of this part is allocated:
2018-04-17 12:26:57 +00:00
- To another build
- To a customer order
"""
return sum ( [
self . allocated_build_count ,
] )
2018-04-30 12:30:15 +00:00
@property
def stock_entries ( self ) :
2019-05-20 11:47:30 +00:00
""" Return all ' in stock ' items. To be in stock:
- customer is None
- belongs_to is None
"""
return self . stock_items . filter ( customer = None , belongs_to = None )
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.
Part may be stored in multiple locations
"""
2017-03-29 12:19:53 +00:00
2019-05-25 14:01:01 +00:00
if self . is_template :
total = sum ( [ variant . total_stock for variant in self . variants . all ( ) ] )
else :
2019-06-04 13:38:52 +00:00
total = self . stock_entries . filter ( status__in = StockStatus . AVAILABLE_CODES ) . aggregate ( total = Sum ( ' quantity ' ) ) [ ' total ' ]
2019-05-20 11:47:30 +00:00
if total :
return total
else :
2020-04-11 12:10:15 +00:00
return Decimal ( 0 )
2017-03-29 12:19:53 +00:00
2018-04-13 12:36:59 +00:00
@property
2018-04-16 11:49:38 +00:00
def has_bom ( self ) :
2018-04-16 12:13:31 +00:00
return self . bom_count > 0
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 """
2018-04-16 13:26:02 +00:00
return self . 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 """
2018-04-16 13:26:02 +00:00
return self . 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
2019-05-20 14:16:00 +00:00
for item in self . bom_items . all ( ) . prefetch_related ( ' sub_part ' ) :
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
@property
def is_bom_valid ( self ) :
""" Check if the BOM is ' valid ' - if the calculated checksum matches the stored value
"""
return self . get_bom_hash ( ) == self . bom_checksum
@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
"""
2019-09-05 03:10:26 +00:00
# Validate each line item too
for item in self . bom_items . all ( ) :
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 ) :
""" Clear the BOM items for the part (delete all BOM lines).
"""
self . bom_items . all ( ) . delete ( )
2019-05-02 08:53:03 +00:00
def required_parts ( self ) :
2019-05-12 02:53:56 +00:00
""" Return a list of parts required to make this part (list of BOM items) """
2019-05-02 08:53:03 +00:00
parts = [ ]
2019-05-20 14:06:57 +00:00
for bom in self . bom_items . all ( ) . select_related ( ' sub_part ' ) :
2019-05-02 08:53:03 +00:00
parts . append ( bom . sub_part )
return parts
2019-07-07 01:22:01 +00:00
def get_allowed_bom_items ( self ) :
""" Return a list of parts which can be added to a BOM for this part.
- Exclude parts which are not ' component ' parts
- Exclude parts which this part is in the BOM for
"""
2019-07-07 01:56:44 +00:00
parts = Part . objects . filter ( component = True ) . exclude ( id = self . id )
2019-07-07 01:22:01 +00:00
parts = parts . exclude ( id__in = [ part . id for part in self . used_in . all ( ) ] )
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. """
2019-05-20 14:06:57 +00:00
for item in self . 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
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 ) :
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
2019-05-20 14:06:57 +00:00
for item in self . bom_items . all ( ) . select_related ( ' sub_part ' ) :
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 ) :
""" 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
2019-05-13 11:54:52 +00:00
def deepCopy ( self , other , * * kwargs ) :
""" Duplicates non-field data from another part.
Does not alter the normal fields of this part ,
but can be used to copy other data linked by ForeignKey refernce .
Keyword Args :
image : If True , copies Part image ( default = True )
bom : If True , copies BOM data ( default = False )
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 ) :
for item in other . bom_items . all ( ) :
2019-08-28 11:12:16 +00:00
# Point the item to THIS part.
# Set the pk to None so a new entry is created.
2019-05-13 11:54:52 +00:00
item . part = self
item . pk = None
item . save ( )
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
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 .
"""
n = self . attachments . count ( )
if self . variant_of :
n + = self . variant_of . attachments . count ( )
return n
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 """
return [ order for order in self . purchase_orders ( ) if order . status in OrderStatus . OPEN ]
def closed_purchase_orders ( self ) :
""" Return a list of closed purchase orders against this part """
return [ order for order in self . purchase_orders ( ) if order . status not in OrderStatus . OPEN ]
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-03-26 06:31:59 +00:00
orders = self . supplier_parts . filter ( purchase_order_line_items__order__status__in = OrderStatus . 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
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
"""
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 ,
related_name = ' attachments ' )
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
"""
part = models . ForeignKey ( Part , on_delete = models . CASCADE , related_name = ' starred_users ' )
user = models . ForeignKey ( User , on_delete = models . CASCADE , related_name = ' starred_parts ' )
2019-05-04 22:48:41 +00:00
class Meta :
unique_together = [ ' part ' , ' user ' ]
2019-05-04 22:46:23 +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
2019-11-18 21:42:10 +00:00
name = models . CharField ( max_length = 100 , help_text = _ ( ' Parameter Name ' ) , unique = True )
2019-08-20 03:02:00 +00:00
2019-11-18 21:42:10 +00:00
units = models . CharField ( max_length = 25 , 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 ' )
2019-11-18 21:42:10 +00:00
part = models . ForeignKey ( Part , on_delete = models . CASCADE , related_name = ' parameters ' , help_text = _ ( ' Parent Part ' ) )
2019-08-20 02:31:43 +00:00
2019-11-18 21:42:10 +00:00
template = models . ForeignKey ( PartParameterTemplate , on_delete = models . CASCADE , related_name = ' instances ' , help_text = _ ( ' Parameter Template ' ) )
2019-08-20 02:31:43 +00:00
2019-11-18 21:42:10 +00:00
data = models . CharField ( max_length = 500 , help_text = _ ( ' Parameter Value ' ) )
2019-08-20 02:31:43 +00:00
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
2019-05-10 10:11:52 +00:00
which parts are required ( and in what quatity ) to make it .
Attributes :
part : Link to the parent part ( the part that will be produced )
sub_part : Link to the child part ( the part that will be consumed )
quantity : Number of ' sub_parts ' consumed to produce one ' part '
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
2018-04-14 04:19:03 +00:00
"""
2018-04-15 11:29:24 +00:00
def get_absolute_url ( self ) :
2019-04-26 13:32:22 +00:00
return reverse ( ' bom-item-detail ' , kwargs = { ' pk ' : self . id } )
2018-04-15 11:29:24 +00:00
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 ' ,
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 ,
2020-03-18 10:28:11 +00:00
' is_template ' : False ,
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 ' ,
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 ,
2020-03-18 10:28:11 +00:00
' is_template ' : False ,
2019-05-05 12:35:39 +00:00
} )
2018-04-14 04:19:03 +00:00
# Quantity required
2019-11-18 21:49:54 +00:00
quantity = models . DecimalField ( default = 1.0 , max_digits = 15 , decimal_places = 5 , validators = [ MinValueValidator ( 0 ) ] , help_text = _ ( ' BOM quantity for this BOM item ' ) )
2018-04-14 04:19:03 +00:00
2019-05-14 14:16:34 +00:00
overage = models . CharField ( max_length = 24 , blank = True , validators = [ validators . validate_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
)
2019-11-18 21:42:10 +00:00
reference = models . CharField ( max_length = 500 , blank = True , 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
2019-11-18 21:42:10 +00:00
note = models . CharField ( max_length = 500 , blank = True , help_text = _ ( ' BOM item notes ' ) )
2019-04-14 08:26:11 +00:00
2019-11-18 21:42:10 +00:00
checksum = models . CharField ( max_length = 128 , blank = True , help_text = _ ( ' BOM line checksum ' ) )
2019-09-05 02:58:11 +00:00
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
"""
# 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 ( ) )
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).
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 ) :
2019-04-27 12:18:07 +00:00
""" Check validity of the BomItem model.
Performs model checks beyond simple field validation .
- A part cannot refer to itself in its BOM
- A part cannot refer to a part which refers to it
"""
2018-04-27 13:23:44 +00:00
2018-04-27 13:42:23 +00:00
# A part cannot refer to itself in its BOM
2019-05-25 11:57:59 +00:00
try :
if self . sub_part is not None and self . part is not None :
if self . part == self . sub_part :
raise ValidationError ( { ' sub_part ' : _ ( ' Part cannot be added to its own Bill of Materials ' ) } )
2019-09-17 10:19:27 +00:00
# TODO - Make sure that there is no recusion
2019-05-25 11:57:59 +00:00
# Test for simple recursion
for item in self . sub_part . bom_items . all ( ) :
if self . part == item . sub_part :
raise ValidationError ( { ' sub_part ' : _ ( " Part ' {p1} ' is used in BOM for ' {p2} ' (recursive) " . format ( p1 = str ( self . part ) , p2 = str ( self . sub_part ) ) ) } )
except Part . DoesNotExist :
# A blank Part will be caught elsewhere
pass
2018-04-27 13:23:44 +00:00
2018-04-14 04:19:03 +00:00
class Meta :
verbose_name = " BOM Item "
# 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
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 .
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 )