""" Build database model definitions """ # -*- coding: utf-8 -*- from __future__ import unicode_literals import decimal import os from datetime import datetime from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator from django.db import models, transaction from django.db.models import Sum, Q from django.db.models.functions import Coalesce from django.db.models.signals import post_save from django.dispatch.dispatcher import receiver from django.urls import reverse from django.utils.translation import ugettext_lazy as _ from markdownx.models import MarkdownxField from mptt.models import MPTTModel, TreeForeignKey from mptt.exceptions import InvalidMove from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin from InvenTree.validators import validate_build_order_reference import common.models import InvenTree.fields import InvenTree.helpers import InvenTree.tasks from part import models as PartModels from stock import models as StockModels from users import models as UserModels def get_next_build_number(): """ Returns the next available BuildOrder reference number """ if Build.objects.count() == 0: return '0001' build = Build.objects.exclude(reference=None).last() attempts = set([build.reference]) reference = build.reference while 1: reference = increment(reference) if reference in attempts: # Escape infinite recursion return reference if Build.objects.filter(reference=reference).exists(): attempts.add(reference) else: break return reference class Build(MPTTModel, ReferenceIndexingMixin): """ A Build object organises the creation of new StockItem objects from other existing StockItem objects. Attributes: part: The part to be built (from component BOM items) reference: Build order reference (required, must be unique) title: Brief title describing the build (required) quantity: Number of units to be built parent: Reference to a Build object for which this Build is required sales_order: References to a SalesOrder object for which this Build is required (e.g. the output of this build will be used to fulfil a sales order) take_from: Location to take stock from to make this build (if blank, can take from anywhere) status: Build status code batch: Batch code transferred to build parts (optional) creation_date: Date the build was created (auto) target_date: Date the build will be overdue completion_date: Date the build was completed (or, if incomplete, the expected date of completion) link: External URL for extra information notes: Text notes completed_by: User that completed the build issued_by: User that issued the build responsible: User (or group) responsible for completing the build """ OVERDUE_FILTER = Q(status__in=BuildStatus.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date()) @staticmethod def get_api_url(): return reverse('api-build-list') def api_instance_filters(self): return { 'parent': { 'exclude_tree': self.pk, } } @classmethod def api_defaults(cls, request): """ Return default values for this model when issuing an API OPTIONS request """ defaults = { 'reference': get_next_build_number(), } if request and request.user: defaults['issued_by'] = request.user.pk return defaults def save(self, *args, **kwargs): self.rebuild_reference_field() try: super().save(*args, **kwargs) except InvalidMove: raise ValidationError({ 'parent': _('Invalid choice for parent build'), }) class Meta: verbose_name = _("Build Order") verbose_name_plural = _("Build Orders") def format_barcode(self, **kwargs): """ Return a JSON string to represent this build as a barcode """ return MakeBarcode( "buildorder", self.pk, { "reference": self.title, "url": self.get_absolute_url(), } ) @staticmethod def filterByDate(queryset, min_date, max_date): """ Filter by 'minimum and maximum date range' - Specified as min_date, max_date - Both must be specified for filter to be applied """ date_fmt = '%Y-%m-%d' # ISO format date string # Ensure that both dates are valid try: min_date = datetime.strptime(str(min_date), date_fmt).date() max_date = datetime.strptime(str(max_date), date_fmt).date() except (ValueError, TypeError): # Date processing error, return queryset unchanged return queryset # Order was completed within the specified range completed = Q(status=BuildStatus.COMPLETE) & Q(completion_date__gte=min_date) & Q(completion_date__lte=max_date) # Order target date falls witin specified range pending = Q(status__in=BuildStatus.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__gte=min_date) & Q(target_date__lte=max_date) # TODO - Construct a queryset for "overdue" orders queryset = queryset.filter(completed | pending) return queryset def __str__(self): prefix = getSetting("BUILDORDER_REFERENCE_PREFIX") return f"{prefix}{self.reference}" def get_absolute_url(self): return reverse('build-detail', kwargs={'pk': self.id}) reference = models.CharField( unique=True, max_length=64, blank=False, help_text=_('Build Order Reference'), verbose_name=_('Reference'), default=get_next_build_number, validators=[ validate_build_order_reference ] ) title = models.CharField( verbose_name=_('Description'), blank=False, max_length=100, help_text=_('Brief description of the build') ) # TODO - Perhaps delete the build "tree" parent = TreeForeignKey( 'self', on_delete=models.SET_NULL, blank=True, null=True, related_name='children', verbose_name=_('Parent Build'), help_text=_('BuildOrder to which this build is allocated'), ) part = models.ForeignKey( 'part.Part', verbose_name=_('Part'), on_delete=models.CASCADE, related_name='builds', limit_choices_to={ 'assembly': True, 'active': True, 'virtual': False, }, help_text=_('Select part to build'), ) sales_order = models.ForeignKey( 'order.SalesOrder', verbose_name=_('Sales Order Reference'), on_delete=models.SET_NULL, related_name='builds', null=True, blank=True, help_text=_('SalesOrder to which this build is allocated') ) take_from = models.ForeignKey( 'stock.StockLocation', verbose_name=_('Source Location'), on_delete=models.SET_NULL, related_name='sourcing_builds', null=True, blank=True, help_text=_('Select location to take stock from for this build (leave blank to take from any stock location)') ) destination = models.ForeignKey( 'stock.StockLocation', verbose_name=_('Destination Location'), on_delete=models.SET_NULL, related_name='incoming_builds', null=True, blank=True, help_text=_('Select location where the completed items will be stored'), ) quantity = models.PositiveIntegerField( verbose_name=_('Build Quantity'), default=1, validators=[MinValueValidator(1)], help_text=_('Number of stock items to build') ) completed = models.PositiveIntegerField( verbose_name=_('Completed items'), default=0, help_text=_('Number of stock items which have been completed') ) status = models.PositiveIntegerField( verbose_name=_('Build Status'), default=BuildStatus.PENDING, choices=BuildStatus.items(), validators=[MinValueValidator(0)], help_text=_('Build status code') ) batch = models.CharField( verbose_name=_('Batch Code'), max_length=100, blank=True, null=True, help_text=_('Batch code for this build output') ) creation_date = models.DateField(auto_now_add=True, editable=False, verbose_name=_('Creation Date')) target_date = models.DateField( null=True, blank=True, verbose_name=_('Target completion date'), help_text=_('Target date for build completion. Build will be overdue after this date.') ) completion_date = models.DateField(null=True, blank=True, verbose_name=_('Completion Date')) completed_by = models.ForeignKey( User, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('completed by'), related_name='builds_completed' ) issued_by = models.ForeignKey( User, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('Issued by'), help_text=_('User who issued this build order'), related_name='builds_issued', ) responsible = models.ForeignKey( UserModels.Owner, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('Responsible'), help_text=_('User responsible for this build order'), related_name='builds_responsible', ) link = InvenTree.fields.InvenTreeURLField( verbose_name=_('External Link'), blank=True, help_text=_('Link to external URL') ) notes = MarkdownxField( verbose_name=_('Notes'), blank=True, help_text=_('Extra build notes') ) def sub_builds(self, cascade=True): """ Return all Build Order objects under this one. """ if cascade: return Build.objects.filter(parent=self.pk) else: descendants = self.get_descendants(include_self=True) Build.objects.filter(parent__pk__in=[d.pk for d in descendants]) def sub_build_count(self, cascade=True): """ Return the number of sub builds under this one. Args: cascade: If True (defualt), include cascading builds under sub builds """ return self.sub_builds(cascade=cascade).count() @property def is_overdue(self): """ Returns true if this build is "overdue": Makes use of the OVERDUE_FILTER to avoid code duplication """ query = Build.objects.filter(pk=self.pk) query = query.filter(Build.OVERDUE_FILTER) return query.exists() @property def active(self): """ Return True if this build is active """ return self.status in BuildStatus.ACTIVE_CODES @property def bom_items(self): """ Returns the BOM items for the part referenced by this BuildOrder """ return self.part.bom_items.all().prefetch_related( 'sub_part' ) @property def tracked_bom_items(self): """ Returns the "trackable" BOM items for this BuildOrder """ items = self.bom_items items = items.filter(sub_part__trackable=True) return items def has_tracked_bom_items(self): """ Returns True if this BuildOrder has trackable BomItems """ return self.tracked_bom_items.count() > 0 @property def untracked_bom_items(self): """ Returns the "non trackable" BOM items for this BuildOrder """ items = self.bom_items items = items.filter(sub_part__trackable=False) return items def has_untracked_bom_items(self): """ Returns True if this BuildOrder has non trackable BomItems """ return self.untracked_bom_items.count() > 0 @property def remaining(self): """ Return the number of outputs remaining to be completed. """ return max(0, self.quantity - self.completed) @property def output_count(self): return self.build_outputs.count() def get_build_outputs(self, **kwargs): """ Return a list of build outputs. kwargs: complete = (True / False) - If supplied, filter by completed status in_stock = (True / False) - If supplied, filter by 'in-stock' status """ outputs = self.build_outputs.all() # Filter by 'in stock' status in_stock = kwargs.get('in_stock', None) if in_stock is not None: if in_stock: outputs = outputs.filter(StockModels.StockItem.IN_STOCK_FILTER) else: outputs = outputs.exclude(StockModels.StockItem.IN_STOCK_FILTER) # Filter by 'complete' status complete = kwargs.get('complete', None) if complete is not None: if complete: outputs = outputs.filter(is_building=False) else: outputs = outputs.filter(is_building=True) return outputs @property def complete_outputs(self): """ Return all the "completed" build outputs """ outputs = self.get_build_outputs(complete=True) # TODO - Ordering? return outputs @property def incomplete_outputs(self): """ Return all the "incomplete" build outputs """ outputs = self.get_build_outputs(complete=False) # TODO - Order by how "complete" they are? return outputs @property def incomplete_count(self): """ Return the total number of "incomplete" outputs """ quantity = 0 for output in self.incomplete_outputs: quantity += output.quantity return quantity @classmethod def getNextBuildNumber(cls): """ Try to predict the next Build Order reference: """ if cls.objects.count() == 0: return None # Extract the "most recent" build order reference builds = cls.objects.exclude(reference=None) if not builds.exists(): return None build = builds.last() ref = build.reference if not ref: return None tries = set(ref) new_ref = ref while 1: new_ref = increment(new_ref) if new_ref in tries: # We are potentially stuck in a loop - simply return the original reference return ref # Check if the existing build reference exists if cls.objects.filter(reference=new_ref).exists(): tries.add(new_ref) else: break return new_ref @property def can_complete(self): """ Returns True if this build can be "completed" - Must not have any outstanding build outputs - 'completed' value must meet (or exceed) the 'quantity' value """ if self.incomplete_count > 0: return False if self.completed < self.quantity: return False if not self.areUntrackedPartsFullyAllocated(): return False # No issues! return True @transaction.atomic def complete_build(self, user): """ Mark this build as complete """ if self.incomplete_count > 0: return self.completion_date = datetime.now().date() self.completed_by = user self.status = BuildStatus.COMPLETE self.save() # Remove untracked allocated stock self.subtractUntrackedStock(user) # Ensure that there are no longer any BuildItem objects # which point to thie Build Order self.allocated_stock.all().delete() @transaction.atomic def cancelBuild(self, user): """ Mark the Build as CANCELLED - Delete any pending BuildItem objects (but do not remove items from stock) - Set build status to CANCELLED - Save the Build object """ for item in self.allocated_stock.all(): item.delete() # Date of 'completion' is the date the build was cancelled self.completion_date = datetime.now().date() self.completed_by = user self.status = BuildStatus.CANCELLED self.save() @transaction.atomic def unallocateStock(self, bom_item=None, output=None): """ Unallocate stock from this Build arguments: - bom_item: Specify a particular BomItem to unallocate stock against - output: Specify a particular StockItem (output) to unallocate stock against """ allocations = BuildItem.objects.filter( build=self, install_into=output ) if bom_item: allocations = allocations.filter(bom_item=bom_item) allocations.delete() @transaction.atomic def create_build_output(self, quantity, **kwargs): """ Create a new build output against this BuildOrder. args: quantity: The quantity of the item to produce kwargs: batch: Override batch code serials: Serial numbers location: Override location """ batch = kwargs.get('batch', self.batch) location = kwargs.get('location', self.destination) serials = kwargs.get('serials', None) """ Determine if we can create a single output (with quantity > 0), or multiple outputs (with quantity = 1) """ multiple = False # Serial numbers are provided? We need to split! if serials: multiple = True # BOM has trackable parts, so we must split! if self.part.has_trackable_parts: multiple = True if multiple: """ Create multiple build outputs with a single quantity of 1 """ for ii in range(quantity): if serials: serial = serials[ii] else: serial = None StockModels.StockItem.objects.create( quantity=1, location=location, part=self.part, build=self, batch=batch, serial=serial, is_building=True, ) else: """ Create a single build output of the given quantity """ StockModels.StockItem.objects.create( quantity=quantity, location=location, part=self.part, build=self, batch=batch, is_building=True ) if self.status == BuildStatus.PENDING: self.status = BuildStatus.PRODUCTION self.save() @transaction.atomic def deleteBuildOutput(self, output): """ Remove a build output from the database: - Unallocate any build items against the output - Delete the output StockItem """ if not output: raise ValidationError(_("No build output specified")) if not output.is_building: raise ValidationError(_("Build output is already completed")) if not output.build == self: raise ValidationError(_("Build output does not match Build Order")) # Unallocate all build items against the output self.unallocateStock(output=output) # Remove the build output from the database output.delete() @transaction.atomic def subtractUntrackedStock(self, user): """ Called when the Build is marked as "complete", this function removes the allocated untracked items from stock. """ items = self.allocated_stock.filter( stock_item__part__trackable=False ) # Remove stock for item in items: item.complete_allocation(user) # Delete allocation items.all().delete() @transaction.atomic def complete_build_output(self, output, user, **kwargs): """ Complete a particular build output - Remove allocated StockItems - Mark the output as complete """ # Select the location for the build output location = kwargs.get('location', self.destination) status = kwargs.get('status', StockStatus.OK) notes = kwargs.get('notes', '') # List the allocated BuildItem objects for the given output allocated_items = output.items_to_install.all() for build_item in allocated_items: # Complete the allocation of stock for that item build_item.complete_allocation(user) # Delete the BuildItem objects from the database allocated_items.all().delete() # Ensure that the output is updated correctly output.build = self output.is_building = False output.location = location output.status = status output.save() output.add_tracking_entry( StockHistoryCode.BUILD_OUTPUT_COMPLETED, user, notes=notes, deltas={ 'status': status, } ) # Increase the completed quantity for this build self.completed += output.quantity self.save() def requiredQuantity(self, part, output): """ Get the quantity of a part required to complete the particular build output. Args: part: The Part object output - The particular build output (StockItem) """ # Extract the BOM line item from the database try: bom_item = PartModels.BomItem.objects.get(part=self.part.pk, sub_part=part.pk) quantity = bom_item.quantity except (PartModels.BomItem.DoesNotExist): quantity = 0 if output: quantity *= output.quantity else: quantity *= self.quantity return quantity def allocatedItems(self, part, output): """ Return all BuildItem objects which allocate stock of to Args: part - The part object output - Build output (StockItem). """ # Remember, if 'variant' stock is allowed to be allocated, it becomes more complicated! variants = part.get_descendants(include_self=True) allocations = BuildItem.objects.filter( build=self, stock_item__part__pk__in=[p.pk for p in variants], install_into=output, ) return allocations def allocatedQuantity(self, part, output): """ Return the total quantity of given part allocated to a given build output. """ allocations = self.allocatedItems(part, output) allocated = allocations.aggregate( q=Coalesce( Sum('quantity'), 0, output_field=models.DecimalField(), ) ) return allocated['q'] def unallocatedQuantity(self, part, output): """ Return the total unallocated (remaining) quantity of a part against a particular output. """ required = self.requiredQuantity(part, output) allocated = self.allocatedQuantity(part, output) return max(required - allocated, 0) def isPartFullyAllocated(self, part, output): """ Returns True if the part has been fully allocated to the particular build output """ return self.unallocatedQuantity(part, output) == 0 def isFullyAllocated(self, output, verbose=False): """ Returns True if the particular build output is fully allocated. """ # If output is not specified, we are talking about "untracked" items if output is None: bom_items = self.untracked_bom_items else: bom_items = self.tracked_bom_items fully_allocated = True for bom_item in bom_items: part = bom_item.sub_part if not self.isPartFullyAllocated(part, output): fully_allocated = False if verbose: print(f"Part {part} is not fully allocated for output {output}") else: break # All parts must be fully allocated! return fully_allocated def areUntrackedPartsFullyAllocated(self): """ Returns True if the un-tracked parts are fully allocated for this BuildOrder """ return self.isFullyAllocated(None) def allocatedParts(self, output): """ Return a list of parts which have been fully allocated against a particular output """ allocated = [] # If output is not specified, we are talking about "untracked" items if output is None: bom_items = self.untracked_bom_items else: bom_items = self.tracked_bom_items for bom_item in bom_items: part = bom_item.sub_part if self.isPartFullyAllocated(part, output): allocated.append(part) return allocated def unallocatedParts(self, output): """ Return a list of parts which have *not* been fully allocated against a particular output """ unallocated = [] # If output is not specified, we are talking about "untracked" items if output is None: bom_items = self.untracked_bom_items else: bom_items = self.tracked_bom_items for bom_item in bom_items: part = bom_item.sub_part if not self.isPartFullyAllocated(part, output): unallocated.append(part) return unallocated @property def required_parts(self): """ Returns a list of parts required to build this part (BOM) """ parts = [] for item in self.bom_items: parts.append(item.sub_part) return parts @property def required_parts_to_complete_build(self): """ Returns a list of parts required to complete the full build """ parts = [] for bom_item in self.bom_items: # Get remaining quantity needed required_quantity_to_complete_build = self.remaining * bom_item.quantity # Compare to net stock if bom_item.sub_part.net_stock < required_quantity_to_complete_build: parts.append(bom_item.sub_part) return parts def availableStockItems(self, part, output): """ Returns stock items which are available for allocation to this build. Args: part - Part object output - The particular build output """ # Grab initial query for items which are "in stock" and match the part items = StockModels.StockItem.objects.filter( StockModels.StockItem.IN_STOCK_FILTER ) # Check if variants are allowed for this part try: bom_item = PartModels.BomItem.objects.get(part=self.part, sub_part=part) allow_part_variants = bom_item.allow_variants except PartModels.BomItem.DoesNotExist: allow_part_variants = False if allow_part_variants: parts = part.get_descendants(include_self=True) items = items.filter(part__pk__in=[p.pk for p in parts]) else: items = items.filter(part=part) # Exclude any items which have already been allocated allocated = BuildItem.objects.filter( build=self, stock_item__part=part, install_into=output, ) items = items.exclude( id__in=[item.stock_item.id for item in allocated.all()] ) # Limit query to stock items which are "downstream" of the source location if self.take_from is not None: items = items.filter( location__in=[loc for loc in self.take_from.getUniqueChildren()] ) # Exclude expired stock items if not common.models.InvenTreeSetting.get_setting('STOCK_ALLOW_EXPIRED_BUILD'): items = items.exclude(StockModels.StockItem.EXPIRED_FILTER) return items @property def is_active(self): """ Is this build active? An active build is either: - PENDING - HOLDING """ return self.status in BuildStatus.ACTIVE_CODES @property def is_complete(self): """ Returns True if the build status is COMPLETE """ return self.status == BuildStatus.COMPLETE @receiver(post_save, sender=Build, dispatch_uid='build_post_save_log') def after_save_build(sender, instance: Build, created: bool, **kwargs): """ Callback function to be executed after a Build instance is saved """ if created: # A new Build has just been created # Run checks on required parts InvenTree.tasks.offload_task('build.tasks.check_build_stock', instance) class BuildOrderAttachment(InvenTreeAttachment): """ Model for storing file attachments against a BuildOrder object """ def getSubdir(self): return os.path.join('bo_files', str(self.build.id)) build = models.ForeignKey(Build, on_delete=models.CASCADE, related_name='attachments') class BuildItem(models.Model): """ A BuildItem links multiple StockItem objects to a Build. These are used to allocate part stock to a build. Once the Build is completed, the parts are removed from stock and the BuildItemAllocation objects are removed. Attributes: build: Link to a Build object bom_item: Link to a BomItem object (may or may not point to the same part as the build) stock_item: Link to a StockItem object quantity: Number of units allocated install_into: Destination stock item (or None) """ @staticmethod def get_api_url(): return reverse('api-build-item-list') def get_absolute_url(self): # TODO - Fix! return '/build/item/{pk}/'.format(pk=self.id) # return reverse('build-detail', kwargs={'pk': self.id}) class Meta: unique_together = [ ('build', 'stock_item', 'install_into'), ] def save(self, *args, **kwargs): self.clean() super().save() def clean(self): """ Check validity of this BuildItem instance. The following checks are performed: - StockItem.part must be in the BOM of the Part object referenced by Build - Allocation quantity cannot exceed available quantity """ self.validate_unique() super().clean() try: # If the 'part' is trackable, then the 'install_into' field must be set! if self.stock_item.part and self.stock_item.part.trackable and not self.install_into: raise ValidationError(_('Build item must specify a build output, as master part is marked as trackable')) # Allocated quantity cannot exceed available stock quantity if self.quantity > self.stock_item.quantity: q = normalize(self.quantity) a = normalize(self.stock_item.quantity) raise ValidationError({ 'quantity': _(f'Allocated quantity ({q}) must not execed available stock quantity ({a})') }) # Allocated quantity cannot cause the stock item to be over-allocated available = decimal.Decimal(self.stock_item.quantity) allocated = decimal.Decimal(self.stock_item.allocation_count()) quantity = decimal.Decimal(self.quantity) if available - allocated + quantity < quantity: raise ValidationError({ 'quantity': _('Stock item is over-allocated') }) # Allocated quantity must be positive if self.quantity <= 0: raise ValidationError({ 'quantity': _('Allocation quantity must be greater than zero'), }) # Quantity must be 1 for serialized stock if self.stock_item.serialized and not self.quantity == 1: raise ValidationError({ 'quantity': _('Quantity must be 1 for serialized stock') }) except (StockModels.StockItem.DoesNotExist, PartModels.Part.DoesNotExist): pass """ Attempt to find the "BomItem" which links this BuildItem to the build. - If a BomItem is already set, and it is valid, then we are ok! """ bom_item_valid = False if self.bom_item and self.build: """ A BomItem object has already been assigned. This is valid if: a) It points to the same "part" as the referenced build b) Either: i) The sub_part points to the same part as the referenced StockItem ii) The BomItem allows variants and the part referenced by the StockItem is a variant of the sub_part referenced by the BomItem iii) The Part referenced by the StockItem is a valid substitute for the BomItem """ if self.build.part == self.bom_item.part: bom_item_valid = self.bom_item.is_stock_item_valid(self.stock_item) elif self.bom_item.inherited: if self.build.part in self.bom_item.part.get_descendants(include_self=False): bom_item_valid = self.bom_item.is_stock_item_valid(self.stock_item) # If the existing BomItem is *not* valid, try to find a match if not bom_item_valid: if self.build and self.stock_item: ancestors = self.stock_item.part.get_ancestors(include_self=True, ascending=True) for idx, ancestor in enumerate(ancestors): try: bom_item = PartModels.BomItem.objects.get(part=self.build.part, sub_part=ancestor) except PartModels.BomItem.DoesNotExist: continue # A matching BOM item has been found! if idx == 0 or bom_item.allow_variants: bom_item_valid = True self.bom_item = bom_item break # BomItem did not exist or could not be validated. # Search for a new one if not bom_item_valid: raise ValidationError({ 'stock_item': _("Selected stock item not found in BOM") }) @transaction.atomic def complete_allocation(self, user): """ Complete the allocation of this BuildItem into the output stock item. - If the referenced part is trackable, the stock item will be *installed* into the build output - If the referenced part is *not* trackable, the stock item will be removed from stock """ item = self.stock_item # For a trackable part, special consideration needed! if item.part.trackable: # Split the allocated stock if there are more available than allocated if item.quantity > self.quantity: item = item.splitStock(self.quantity, None, user) # Make sure we are pointing to the new item self.stock_item = item self.save() # Install the stock item into the output item.belongs_to = self.install_into item.save() else: # Simply remove the items from stock item.take_stock(self.quantity, user) def getStockItemThumbnail(self): """ Return qualified URL for part thumbnail image """ thumb_url = None if self.stock_item and self.stock_item.part: try: # Try to extract the thumbnail thumb_url = self.stock_item.part.image.thumbnail.url except: pass if thumb_url is None and self.bom_item and self.bom_item.sub_part: try: thumb_url = self.bom_item.sub_part.image.thumbnail.url except: pass if thumb_url is not None: return InvenTree.helpers.getMediaUrl(thumb_url) else: return InvenTree.helpers.getBlankThumbnail() build = models.ForeignKey( Build, on_delete=models.CASCADE, related_name='allocated_stock', verbose_name=_('Build'), help_text=_('Build to allocate parts') ) # Internal model which links part <-> sub_part # We need to track this separately, to allow for "variant' stock bom_item = models.ForeignKey( PartModels.BomItem, on_delete=models.CASCADE, related_name='allocate_build_items', blank=True, null=True, ) stock_item = models.ForeignKey( 'stock.StockItem', on_delete=models.CASCADE, related_name='allocations', verbose_name=_('Stock Item'), help_text=_('Source stock item'), limit_choices_to={ 'sales_order': None, 'belongs_to': None, } ) quantity = models.DecimalField( decimal_places=5, max_digits=15, default=1, validators=[MinValueValidator(0)], verbose_name=_('Quantity'), help_text=_('Stock quantity to allocate to build') ) install_into = models.ForeignKey( 'stock.StockItem', on_delete=models.SET_NULL, blank=True, null=True, related_name='items_to_install', verbose_name=_('Install into'), help_text=_('Destination stock item'), limit_choices_to={ 'is_building': True, } )