class Build(MPTTModel): """ 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 """ OVERDUE_FILTER = Q(status__in=BuildStatus.ACTIVE_CODES) & ~Q( target_date=None) & Q(target_date__lte=datetime.now().date()) class Meta: verbose_name = _("Build Order") verbose_name_plural = _("Build Orders") @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'), 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) 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) completed_by = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, related_name='builds_completed') 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 is_overdue(self): """ Returns true if this build is "overdue": - Not completed - Target date is "in the past" """ # Cannot be deemed overdue if target_date is not set if self.target_date is None: return False today = datetime.now().date() return self.active and self.target_date < today @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 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 build = cls.objects.last() ref = build.reference if not ref: return None tries = set() while 1: new_ref = increment(ref) if new_ref in tries: # We are potentially stuck in a loop - simply return the original reference return ref if cls.objects.filter(reference=new_ref).exists(): tries.add(new_ref) new_ref = increment(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 # No issues! return True @transaction.atomic def complete_build(self, user): """ Mark this build as complete """ if not self.can_complete: return self.completion_date = datetime.now().date() self.completed_by = user self.status = BuildStatus.COMPLETE self.save() # 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() def getAutoAllocations(self, output): """ Return a list of StockItem objects which will be allocated using the 'AutoAllocate' function. For each item in the BOM for the attached Part, the following tests must *all* evaluate to True, for the part to be auto-allocated: - The sub_item in the BOM line must *not* be trackable - There is only a single stock item available (which has not already been allocated to this build) - The stock item has an availability greater than zero Returns: A list object containing the StockItem objects to be allocated (and the quantities). Each item in the list is a dict as follows: { 'stock_item': stock_item, 'quantity': stock_quantity, } """ allocations = [] """ Iterate through each item in the BOM """ for bom_item in self.bom_items: part = bom_item.sub_part # Skip any parts which are already fully allocated if self.isPartFullyAllocated(part, output): continue # How many parts are required to complete the output? required = self.unallocatedQuantity(part, output) # Grab a list of stock items which are available stock_items = self.availableStockItems(part, output) # Ensure that the available stock items are in the correct location if self.take_from is not None: # Filter for stock that is located downstream of the designated location stock_items = stock_items.filter(location__in=[ loc for loc in self.take_from.getUniqueChildren() ]) # Only one StockItem to choose from? Default to that one! if stock_items.count() == 1: stock_item = stock_items[0] # Double check that we have not already allocated this stock-item against this build build_items = BuildItem.objects.filter(build=self, stock_item=stock_item, install_into=output) if len(build_items) > 0: continue # How many items are actually available? if stock_item.quantity > 0: # Only take as many as are available if stock_item.quantity < required: required = stock_item.quantity allocation = { 'stock_item': stock_item, 'quantity': required, } allocations.append(allocation) return allocations @transaction.atomic def unallocateStock(self, output=None, part=None): """ Deletes all stock allocations for this build. Args: output: Specify which build output to delete allocations (optional) """ allocations = BuildItem.objects.filter(build=self.pk) if output: allocations = allocations.filter(install_into=output.pk) if part: allocations = allocations.filter(stock_item__part=part) # Remove all the allocations 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) # Remove the build output from the database output.delete() @transaction.atomic def autoAllocate(self, output): """ Run auto-allocation routine to allocate StockItems to this Build. Args: output: If specified, only auto-allocate against the given built output Returns a list of dict objects with keys like: { 'stock_item': item, 'quantity': quantity, } See: getAutoAllocations() """ allocations = self.getAutoAllocations(output) for item in allocations: # Create a new allocation build_item = BuildItem( build=self, stock_item=item['stock_item'], quantity=item['quantity'], install_into=output, ) build_item.save() @transaction.atomic def completeBuildOutput(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) # List the allocated BuildItem objects for the given output allocated_items = output.items_to_install.all() for build_item in allocated_items: # TODO: This is VERY SLOW as each deletion from the database takes ~1 second to complete # TODO: Use celery / redis to offload the actual object deletion... # REF: https://www.botreetechnologies.com/blog/implementing-celery-using-django-for-background-task-processing # REF: https://code.tutsplus.com/tutorials/using-celery-with-django-for-background-task-processing--cms-28732 # 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.save() output.addTransactionNote(_('Completed build output'), user, system=True) # 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.remaining return quantity def allocatedItems(self, part, output): """ Return all BuildItem objects which allocate stock of <part> to <output> Args: part - The part object output - Build output (StockItem). """ allocations = BuildItem.objects.filter( build=self, stock_item__part=part, 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)) 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): """ Returns True if the particular build output is fully allocated. """ for bom_item in self.bom_items: part = bom_item.sub_part if not self.isPartFullyAllocated(part, output): return False # All parts must be fully allocated! return True def allocatedParts(self, output): """ Return a list of parts which have been fully allocated against a particular output """ allocated = [] for bom_item in self.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 = [] for bom_item in self.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 dict of parts required to build this part (BOM) """ parts = [] for item in self.part.bom_items.all().prefetch_related('sub_part'): parts.append(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) 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
class Table(models.Model): objects = ActiveTableManager.from_queryset(TableQuerySet)() with_hidden = AllTablesManager.from_queryset(TableQuerySet)() dataset = models.ForeignKey(Dataset, on_delete=models.CASCADE, null=False, blank=False) default = models.BooleanField(null=False, blank=False) name = models.CharField(max_length=255, null=False, blank=False) options = models.JSONField(null=True, blank=True) ordering = ArrayField(models.CharField(max_length=63), null=False, blank=False) filtering = ArrayField(models.CharField(max_length=63), null=True, blank=True) search = ArrayField(models.CharField(max_length=63), null=True, blank=True) version = models.ForeignKey(Version, on_delete=models.CASCADE, null=False, blank=False) import_date = models.DateTimeField(null=True, blank=True) description = MarkdownxField(null=True, blank=True) hidden = models.BooleanField(default=False) api_enabled = models.BooleanField(default=True) def __str__(self): return "{}.{}.{}".format(self.dataset.slug, self.version.name, self.name) @property def collect_date(self): return self.version.collected_at @property def data_table(self): return self.data_tables.get_current_active() @property def db_table(self): return self.data_table.db_table_name @property def fields(self): return self.field_set.all() @property def enabled(self): return not self.hidden @property def schema(self): db_fields_to_rows_fields = { "binary": rows_fields.BinaryField, "bool": rows_fields.BoolField, "date": rows_fields.DateField, "datetime": rows_fields.DatetimeField, "decimal": rows_fields.DecimalField, "email": rows_fields.EmailField, "float": rows_fields.FloatField, "integer": rows_fields.IntegerField, "json": rows_fields.JSONField, "string": rows_fields.TextField, "text": rows_fields.TextField, } return OrderedDict([ (n, db_fields_to_rows_fields.get(t, rows_fields.Field)) for n, t in self.fields.values_list("name", "type") ]) @property def model_name(self): full_name = self.dataset.slug + "-" + self.name parts = full_name.replace("_", "-").replace(" ", "-").split("-") return "".join([word.capitalize() for word in parts]) @cached_property def dynamic_table_config(self): return DynamicTableConfig.get_dynamic_table_customization( self.dataset.slug, self.name) def get_dynamic_model_managers(self): managers = {"objects": DatasetTableModelQuerySet.as_manager()} if self.dynamic_table_config: managers.update(self.dynamic_table_config.get_model_managers()) return managers def get_dynamic_model_mixins(self): mixins = [DatasetTableModelMixin] custom_mixins = [] if not self.dynamic_table_config else self.dynamic_table_config.get_model_mixins( ) return custom_mixins + mixins def get_model(self, cache=True, data_table=None): # TODO: the current dynamic model registry is handled by Brasil.IO's # code but it needs to be delegated to dynamic_models. data_table = data_table or self.data_table db_table = data_table.db_table_name # TODO: limit the max number of items in DYNAMIC_MODEL_REGISTRY cache_key = (self.id, db_table) if cache and cache_key in DYNAMIC_MODEL_REGISTRY: return DYNAMIC_MODEL_REGISTRY[cache_key] # TODO: unregister the model in Django if already registered (cache_key # in DYNAMIC_MODEL_REGISTRY and not cache) fields = {field.name: field.field_class for field in self.fields} fields["search_data"] = SearchVectorField(null=True) ordering = self.ordering or [] filtering = self.filtering or [] search = self.search or [] indexes = [] # TODO: add has_choices fields also if ordering: indexes.append( django_indexes.Index( name=make_index_name(db_table, "order", ordering), fields=ordering, )) if filtering: for field_name in filtering: if ordering == [field_name]: continue indexes.append( django_indexes.Index(name=make_index_name( db_table, "filter", [field_name]), fields=[field_name])) if search: indexes.append( pg_indexes.GinIndex(name=make_index_name( db_table, "search", ["search_data"]), fields=["search_data"])) managers = self.get_dynamic_model_managers() mixins = self.get_dynamic_model_mixins() meta = {"ordering": ordering, "indexes": indexes, "db_table": db_table} Model = dynamic_models.create_model_class( name=self.model_name, module="core.models", fields=fields, mixins=mixins, meta=meta, managers=managers, ) Model.extra = { "filtering": filtering, "ordering": ordering, "search": search, } DYNAMIC_MODEL_REGISTRY[cache_key] = Model return Model def get_model_declaration(self): Model = self.get_model() return dynamic_models.model_source_code(Model) def invalidate_cache(self): invalidate(self.db_table)
class Question(models.Model): # Draft->草稿 Publisher -> 已发布 STATUS = (("O", "Open"), ("C", "Close"), ("D", "Draft")) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="q_author", verbose_name="提问者") title = models.CharField(max_length=255, unique=True, verbose_name="标题") slug = models.SlugField(max_length=80, null=True, blank=True, verbose_name='(URL)别名') status = models.CharField(max_length=1, choices=STATUS, default="O", verbose_name="问题状态") content = MarkdownxField(verbose_name="内容") tags = TaggableManager(help_text="多个标签使用,(英文)隔开", verbose_name="标签") # 通过GenericRelation关联到Vote表,给问题投票 不是实际的字段 votes = GenericRelation(Vote, verbose_name="投票情况") has_answer = models.BooleanField(default=False, verbose_name="接受回答") created_at = models.DateTimeField(db_index=True, auto_now_add=True, verbose_name='创建时间') updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间') objects = QuestionQuerySet.as_manager() class Meta: verbose_name = "问题" verbose_name_plural = verbose_name ordering = ('-updated_at', '-created_at') def __str__(self): return self.title def save(self, force_insert=False, force_update=False, using=None, update_fields=None): if not self.slug: self.slug = slugify(self.title) super(Question, self).save() def get_markdown(self): return markdownify(self.content) def total_votes(self): """总票数""" # self.votes.values_list 相当于 Vote.objects.values_list() # Counter 分别列出"value" Ture 的数量 与 "value" 为False的数量 的字典 dic = Counter(self.votes.values_list("value", flat=True)) return dic[True] - dic[False] # 获取所有回答 def get_answers(self): # self为参数表示当前问题有多少回答 return Answer.objects.filter(question=self).prefetch_related( 'user', 'question') # 回答的数量 def count_answers(self): return self.get_answers().count() def get_upvoters(self): """赞同用户""" # return [vote.user for vote in self.votes.filter(value=True)] return [ vote.user for vote in self.votes.filter( value=True).select_related('user').prefetch_related('vote') ] def get_downvoters(self): """踩的用户""" # return [vote.user for vote in self.votes.filter(value=False)] return [ vote.user for vote in self.votes.filter( value=False).select_related('user').prefetch_related('vote') ] # 获取问题接受的回答 def get_accepted_answer(self): return Answer.objects.get(question=self, is_answer=True)
class MyModel(models.Model): myfield = MarkdownxField()
class Question(models.Model): """Model class to contain every question in the forum.""" OPEN = "O" CLOSED = "C" DRAFT = "D" STATUS = ( (OPEN, _("Open")), (CLOSED, _("Closed")), (DRAFT, _("Draft")), ) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) title = models.CharField(max_length=200, unique=True, blank=False) timestamp = models.DateTimeField(auto_now_add=True) slug = models.SlugField(max_length=80, null=True, blank=True) status = models.CharField(max_length=1, choices=STATUS, default=DRAFT) content = MarkdownxField() has_answer = models.BooleanField(default=False) total_votes = models.IntegerField(default=0) votes = GenericRelation(Vote) tags = TaggableManager() objects = QuestionQuerySet.as_manager() class Meta: ordering = ["-timestamp"] verbose_name = _("Question") verbose_name_plural = _("Questions") def save(self, *args, **kwargs): if not self.slug: self.slug = slugify(f"{self.title}-{self.id}", to_lower=True, max_length=80) super().save(*args, **kwargs) def __str__(self): return self.title @property def count_answers(self): return Answer.objects.filter(question=self).count() def count_votes(self): """Method to update the sum of the total votes. Uses this complex query to avoid race conditions at database level.""" dic = Counter(self.votes.values_list("value", flat=True)) Question.objects.filter(id=self.id).update(total_votes=dic[True] - dic[False]) self.refresh_from_db() def get_upvoters(self): """Returns a list containing the users who upvoted the instance.""" return [vote.user for vote in self.votes.filter(value=True)] def get_downvoters(self): """Returns a list containing the users who downvoted the instance.""" return [vote.user for vote in self.votes.filter(value=False)] def get_answers(self): return Answer.objects.filter(question=self) def get_accepted_answer(self): return Answer.objects.get(question=self, is_answer=True) def get_markdown(self): return markdownify(self.content)
class Company(models.Model): """ A Company object represents an external company. It may be a supplier or a customer or a manufacturer (or a combination) - A supplier is a company from which parts can be purchased - A customer is a company to which parts can be sold - A manufacturer is a company which manufactures a raw good (they may or may not be a "supplier" also) Attributes: name: Brief name of the company description: Longer form description website: URL for the company website address: Postal address phone: contact phone number email: contact email address link: Secondary URL e.g. for link to internal Wiki page image: Company image / logo notes: Extra notes about the company is_customer: boolean value, is this company a customer is_supplier: boolean value, is this company a supplier is_manufacturer: boolean value, is this company a manufacturer """ name = models.CharField(max_length=100, blank=False, unique=True, help_text=_('Company name'), verbose_name=_('Company name')) description = models.CharField(max_length=500, verbose_name=_('Company description'), help_text=_('Description of the company')) website = models.URLField(blank=True, verbose_name=_('Website'), help_text=_('Company website URL')) address = models.CharField(max_length=200, verbose_name=_('Address'), blank=True, help_text=_('Company address')) phone = models.CharField(max_length=50, verbose_name=_('Phone number'), blank=True, help_text=_('Contact phone number')) email = models.EmailField(blank=True, verbose_name=_('Email'), help_text=_('Contact email address')) contact = models.CharField(max_length=100, verbose_name=_('Contact'), blank=True, help_text=_('Point of contact')) link = InvenTreeURLField( blank=True, help_text=_('Link to external company information')) image = StdImageField( upload_to=rename_company_image, null=True, blank=True, variations={'thumbnail': (128, 128)}, delete_orphans=True, ) notes = MarkdownxField(blank=True) is_customer = models.BooleanField( default=False, help_text=_('Do you sell items to this company?')) is_supplier = models.BooleanField( default=True, help_text=_('Do you purchase items from this company?')) is_manufacturer = models.BooleanField( default=False, help_text=_('Does this company manufacture parts?')) def __str__(self): """ Get string representation of a Company """ return "{n} - {d}".format(n=self.name, d=self.description) def get_absolute_url(self): """ Get the web URL for the detail view for this Company """ return reverse('company-detail', kwargs={'pk': self.id}) def get_image_url(self): """ Return the URL of the image for this company """ if self.image: return getMediaUrl(self.image.url) else: return getBlankImage() def get_thumbnail_url(self): """ Return the URL for the thumbnail image for this Company """ if self.image: return getMediaUrl(self.image.thumbnail.url) else: return getBlankThumbnail() @property def manufactured_part_count(self): """ The number of parts manufactured by this company """ return self.manufactured_parts.count() @property def has_manufactured_parts(self): return self.manufactured_part_count > 0 @property def supplied_part_count(self): """ The number of parts supplied by this company """ return self.supplied_parts.count() @property def has_supplied_parts(self): """ Return True if this company supplies any parts """ return self.supplied_part_count > 0 @property def parts(self): """ Return SupplierPart objects which are supplied or manufactured by this company """ return SupplierPart.objects.filter( Q(supplier=self.id) | Q(manufacturer=self.id)) @property def part_count(self): """ The number of parts manufactured (or supplied) by this Company """ return self.parts.count() @property def has_parts(self): return self.part_count > 0 @property def stock_items(self): """ Return a list of all stock items supplied or manufactured by this company """ stock = apps.get_model('stock', 'StockItem') return stock.objects.filter( Q(supplier_part__supplier=self.id) | Q(supplier_part__manufacturer=self.id)).all() @property def stock_count(self): """ Return the number of stock items supplied or manufactured by this company """ return self.stock_items.count() def outstanding_purchase_orders(self): """ Return purchase orders which are 'outstanding' """ return self.purchase_orders.filter(status__in=PurchaseOrderStatus.OPEN) def pending_purchase_orders(self): """ Return purchase orders which are PENDING (not yet issued) """ return self.purchase_orders.filter(status=PurchaseOrderStatus.PENDING) def closed_purchase_orders(self): """ Return purchase orders which are not 'outstanding' - Complete - Failed / lost - Returned """ return self.purchase_orders.exclude( status__in=PurchaseOrderStatus.OPEN) def complete_purchase_orders(self): return self.purchase_orders.filter(status=PurchaseOrderStatus.COMPLETE) def failed_purchase_orders(self): """ Return any purchase orders which were not successful """ return self.purchase_orders.filter( status__in=PurchaseOrderStatus.FAILED)
class Order(MetadataMixin, ReferenceIndexingMixin): """ Abstract model for an order. Instances of this class: - PuchaseOrder Attributes: reference: Unique order number / reference / code description: Long form description (required) notes: Extra note field (optional) creation_date: Automatic date of order creation created_by: User who created this order (automatically captured) issue_date: Date the order was issued complete_date: Date the order was completed responsible: User (or group) responsible for managing the order """ def save(self, *args, **kwargs): self.rebuild_reference_field() if not self.creation_date: self.creation_date = datetime.now().date() super().save(*args, **kwargs) class Meta: abstract = True description = models.CharField(max_length=250, verbose_name=_('Description'), help_text=_('Order description')) link = models.URLField(blank=True, verbose_name=_('Link'), help_text=_('Link to external page')) creation_date = models.DateField(blank=True, null=True, verbose_name=_('Creation Date')) created_by = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, related_name='+', verbose_name=_('Created By')) responsible = models.ForeignKey( UserModels.Owner, on_delete=models.SET_NULL, blank=True, null=True, help_text=_('User or group responsible for this order'), verbose_name=_('Responsible'), related_name='+', ) notes = MarkdownxField(blank=True, verbose_name=_('Notes'), help_text=_('Order notes')) def get_total_price(self, target_currency=currency_code_default()): """ Calculates the total price of all order lines, and converts to the specified target currency. If not specified, the default system currency is used. If currency conversion fails (e.g. there are no valid conversion rates), then we simply return zero, rather than attempting some other calculation. """ total = Money(0, target_currency) # gather name reference price_ref_tag = 'sale_price' if isinstance( self, SalesOrder) else 'purchase_price' # order items for line in self.lines.all(): price_ref = getattr(line, price_ref_tag) if not price_ref: continue try: total += line.quantity * convert_money(price_ref, target_currency) except MissingRate: # Record the error, try to press on kind, info, data = sys.exc_info() Error.objects.create( kind=kind.__name__, info=info, data='\n'.join(traceback.format_exception( kind, info, data)), path='order.get_total_price', ) logger.error(f"Missing exchange rate for '{target_currency}'") # Return None to indicate the calculated price is invalid return None # extra items for line in self.extra_lines.all(): if not line.price: continue try: total += line.quantity * convert_money(line.price, target_currency) except MissingRate: # Record the error, try to press on kind, info, data = sys.exc_info() Error.objects.create( kind=kind.__name__, info=info, data='\n'.join(traceback.format_exception( kind, info, data)), path='order.get_total_price', ) logger.error(f"Missing exchange rate for '{target_currency}'") # Return None to indicate the calculated price is invalid return None # set decimal-places total.decimal_places = 4 return total
class Dataset(models.Model): class Meta: db_table = 'Dataset' id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='owner_datasets') source_uri = models.CharField(blank=False, null=False, max_length=128) source_region = models.CharField(choices=AwsRegionType.choices(), default=AwsRegionType.eu_west_1, max_length=128, help_text='Give the AWS region of the S3 bucket. Required to show the content') is_public = models.BooleanField(default=False) title = models.CharField(max_length=140, blank=False, null=False, help_text='Give your dataset a descriptive title') description = MarkdownxField(help_text='Additional information about your dataset') task_type = models.CharField(choices=TaskType.choices(), default=TaskType.single_image_label, max_length=128, help_text='Task Type: label a single image or compare two images') labels_per_task = models.PositiveSmallIntegerField(default=1, help_text='How many labels should be saved for each task') label_names = ArrayField(models.CharField(max_length=128, blank=False), help_text='Give a comma-separated list of the labels in your dataset. Example: "hotdog, not hotdog"') keys = ArrayField(models.CharField(max_length=256), null=True, blank=True) source_data = JSONField(default=dict, help_text='Keys in your dataset, will be automatically fetched and overwritten each time you save.') admins = models.ManyToManyField(User, related_name='admin_datasets', blank=True) contributors = models.ManyToManyField(User, related_name='contributor_datasets', blank=True) invite_key = models.CharField(max_length=128, null=True) def __str__(self): return f'{self.title} - {self.source_region}' def clean_fields(self, exclude=None): if S3_ARN_PREFIX in self.source_uri: self.source_uri = self.source_uri.replace(S3_ARN_PREFIX, '') try: s3 = boto3.resource('s3') s3.meta.client.head_bucket(Bucket=self.source_uri) except ClientError as e: # If a client error is thrown, then check that it was a 404 error. # If it was a 404 error, then the bucket does not exist. error_code = int(e.response['Error']['Code']) if error_code == 403: raise ValidationError('Private S3 bucket. Can\'t access data') elif error_code == 404: raise ValidationError('Could not find S3 bucket %s' % self.source_uri) except ParamValidationError as e: raise ValidationError('Wrong source bucket name. %s' % e) def save(self, *args, **kwargs): if not self.invite_key: self.invite_key = generate_invite_key(self) super(Dataset, self).save(*args, **kwargs) def clean_keys_from_source(self): bucket_name = get_s3_bucket_from_str(str(self.source_uri)) s3_bucket = boto3.resource('s3').Bucket(bucket_name) removed_keys = [] for key in self.keys: should_delete_key = False try: file_size = s3_bucket.Object(key).content_length if file_size == 0: should_delete_key = True except ClientError as e: error_code = int(e.response['Error']['Code']) if error_code == 404: should_delete_key = True else: logger.warning(f'Cleaning dataset {self.id} - received error {e}') if should_delete_key: self.delete_key_from_dataset(key) removed_keys.append(key) logger.info(f'Removed {len(removed_keys)} keys') self.keys = list(set(self.keys) - set(removed_keys)) self.save() def delete_key_from_dataset(self, key): bucket_name = get_s3_bucket_from_str(str(self.source_uri)) is_deleted = delete_s3_object(bucket_name, key) if not is_deleted: return 'Could not delete file', None, None tasks_with_deleted_file = self.tasks.filter(definition__contains=key) task_ids_with_deleted_file = tasks_with_deleted_file.values_list("id", flat=True) labels_for_tasks_with_deleted_file = Label.objects.filter(task_id__in=task_ids_with_deleted_file) task_count = tasks_with_deleted_file.count() label_count = labels_for_tasks_with_deleted_file.count() labels_for_tasks_with_deleted_file.delete() tasks_with_deleted_file.delete() logger.info(f'Removed key {key} in S3, {task_count} tasks and {label_count} labels') return None, task_count, label_count def fetch_keys_from_source(self): bucket_name = get_s3_bucket_from_str(str(self.source_uri)) s3 = boto3.client('s3') paginator = s3.get_paginator('list_objects') page_response = paginator.paginate(Bucket=bucket_name) keys = [] logger.info('start to fetch keys from bucket: %s', bucket_name) for response in page_response: if response['ResponseMetadata']['HTTPStatusCode'] == 200: contents = response['Contents'] keys += list(map(lambda obj: obj['Key'], contents)) logger.info('received %s keys from bucket %s', len(contents), bucket_name) self.keys = keys if self.task_type == TaskType.two_image_comparison.value: prev_tasks_with_labels = self.tasks.filter(labels__isnull=False) prev_task_definitions = set(prev_tasks_with_labels.values_list('definition', flat=True)) task_definitions = self.get_task_definitions_from_keys(keys=keys, prev_task_definitions=prev_task_definitions) else: existing_keys = list(self.tasks.values_list('definition', flat=True)) task_definitions = [key for key in keys if key not in existing_keys] logger.info(f'Start to create {len(task_definitions)} new tasks') Task.objects.bulk_create([ Task(dataset=self, definition=definition) for definition in task_definitions ]) logger.info(f'Done creating {len(task_definitions)} tasks') self.save() @property def is_done(self) -> bool: return self.labels.count() >= self.nr_required_labels @property def nr_required_labels(self) -> int: return self.tasks.count() * self.labels_per_task def is_user_authorised_to_contribute(self, user: User) -> bool: if self.is_public: return True if user.is_anonymous: return False return self.user == user or user.admin_datasets.filter(id=self.id).exists() \ or user.contributor_datasets.filter(id=self.id).exists() def is_user_authorised_admin(self, user: User) -> bool: if user.is_anonymous: return False else: return self.user == user or user.admin_datasets.filter(id=self.id).exists() @property def users(self): return User.objects.filter(Q(contributor_datasets=self) | Q(admin_datasets=self) | Q(owner_datasets=self)) def get_leaderboard_users(self): return self.users.annotate(nr_labels=Count('labels', filter=Q(labels__dataset=self, labels__action=LabelActionType.solve.value)) ).filter(nr_labels__gt=0).order_by('-nr_labels') @property def invite_link(self): return reverse('signup_with_invite', args=[str(self.invite_key)]) @staticmethod def get_task_definitions_from_keys(keys: List[str], prev_task_definitions: Collection[str] = None, ratio: float = 0.01, max_nr_tasks: int = 50000) -> List[str]: """ :param keys: list of keys to be compared to each other :param prev_task_definitions: list of previous tasks that should not be removed in new calculation :param ratio: ratio of comparison tasks to be opened for each key to all others. 1 means every key with all other keys :param max_nr_tasks: max number of tasks that can be returned. return can be smaller depending on nr. of keys and ratio :return: list of tasks = list of tuples of two keys """ if not prev_task_definitions: prev_task_definitions = [] nr_prev_tasks = len(prev_task_definitions) if nr_prev_tasks >= max_nr_tasks: raise GenerateComparisonTasksException( f'Cannot generate new comparison tasks. {nr_prev_tasks} previous tasks' f' is more than {max_nr_tasks} max_nr_tasks') if ratio > 1: raise GenerateComparisonTasksException(f'Ratio {ratio} is larger 1. Duplicate task creation not supported') all_combinations = list(combinations(keys, 2)) should_ignore_ratio = len(all_combinations) * ratio < len(keys) if should_ignore_ratio: ratio = 1 all_combinations = set(all_combinations) - set(prev_task_definitions) nr_new_tasks = min(len(all_combinations) * ratio, max_nr_tasks - nr_prev_tasks) new_tasks = sample(all_combinations, int(nr_new_tasks)) tasks_as_str = [f'{k1},{k2}' for k1, k2 in new_tasks] return tasks_as_str def get_unique_processed_files(self): tasks_with_labels = self.tasks.filter(labels__isnull=False) if self.task_type == TaskType.two_image_comparison.value: annotated_labels = tasks_with_labels.annotate( key1=Func(F('definition'), Value(','), Value(1), function='split_part'), key2=Func(F('definition'), Value(','), Value(2), function='split_part')) unique_keys = set(annotated_labels.values_list('key1', flat=True)).union( set(annotated_labels.values_list('key2', flat=True))) return unique_keys else: return tasks_with_labels.all()
class Article(models.Model): """Represents a piece of news.""" title = models.CharField('titre', max_length=300, help_text="Titre de l'article") slug = models.SlugField( max_length=100, unique=True, help_text=( "Un court identifiant généré après la création de l'article.")) introduction = models.TextField( blank=True, default='', help_text=( "Chapeau introductif qui sera affiché sous le titre de l'article. " "Utilisez-le pour résumer le contenu de l'article ou introduire " "le sujet.")) content = MarkdownxField( 'contenu', help_text="Contenu complet de l'article (Markdown est supporté).") published = models.DateTimeField('publié le', default=now) modified = models.DateTimeField('modifié le', auto_now=True) image = models.ImageField('illustration', blank=True, null=True, upload_to='articles/') display_image = models.BooleanField( "afficher l'illustration", default=True, help_text=( "Cocher pour que l'illustration soit affichée sous le chapeau " "introductif de l'article.")) pinned = models.BooleanField( 'épinglé', default=False, blank=True, help_text=( "Cocher pour que l'article soit épinglé et affiché en priorité.")) # ^blank=True to allow True of False value (otherwise # validation would force pinned to be True) # see: https://docs.djangoproject.com/fr/2.0/ref/forms/fields/#booleanfield categories = models.ManyToManyField( 'Category', blank=True, help_text="Catégories auxquelles rattacher l'article", verbose_name='catégories') active = models.BooleanField( 'actif', default=True, blank=True, help_text=("Décocher pour que l'article soit archivé. " "Il ne sera alors plus affiché sur le site.")) def save(self, *args, **kwargs): """Assign a slug on article creation.""" if self.pk is None and not self.slug: self.slug = slugify(self.title) super().save(*args, **kwargs) class Meta: # noqa verbose_name = 'article' ordering = ( '-active', '-pinned', '-published', ) def get_absolute_url(self): """Return the article's absolute url.""" return reverse('api:article-detail', args=[str(self.slug)]) # Permissions @staticmethod def has_read_permission(request): return True def has_object_read_permission(self, request): return True def __str__(self): return str(self.title)
class Question(models.Model): STATUS = ( ('O', 'Open'), ('C', 'Close'), ('D', 'Draft'), ) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='q_author', verbose_name='提问者') title = models.CharField(max_length=255, unique=True, verbose_name='标题') slug = models.SlugField(max_length=255, verbose_name='(URL)别名') status = models.CharField(max_length=1, choices=STATUS, default='O', verbose_name='问题状态') content = MarkdownxField(verbose_name='内容') tags = TaggableManager(help_text='多个标签用,(英文)隔开', verbose_name='标签') has_answer = models.BooleanField(default=False, verbose_name='接受回答') votes = GenericRelation( Vote, verbose_name='投票情况' ) # 通过GenericRelation关联到Vote表定义的GenericForeignKey,vote本身不是实际的字段 created_at = models.DateTimeField(db_index=True, auto_now_add=True, verbose_name='创建时间') updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间') objects = QuestionQuerrySet.as_manager() class Meta: verbose_name = '问题' verbose_name_plural = verbose_name ordering = ('-created_at', ) def __str__(self): return self.title def save(self, *args, **kwargs): if not self.slug: self.slug = slugify(self.title) super(Question, self).save(*args, **kwargs) def get_markdown(self): return markdownify(self.content) def total_votes(self): """得票数""" dic = Counter(self.votes.values_list('value', flat=True)) return dic[True] - dic[False] def get_answers(self): """所有回答""" return Answer.objects.filter(question=self).select_related( 'user', 'question') def count_answers(self): """总回答数""" return self.get_answers().count() def get_upvoters(self): """赞成的人""" return [ vote.user for vote in self.votes.filter( value=True).select_related('user').prefetch_related('vote') ] def get_downvoters(self): """反对的人""" return [ vote.user for vote in self.votes.filter( value=False).select_related('user').prefetch_related('vote') ]
class CodeOfConduct(SingleActiveModel): title = models.CharField(null=True, blank=True, max_length=100) description = MarkdownxField(null=True, blank=True) def __str__(self): return self.title
class StockItem(MPTTModel): """ A StockItem object represents a quantity of physical instances of a part. Attributes: parent: Link to another StockItem from which this StockItem was created uid: Field containing a unique-id which is mapped to a third-party identifier (e.g. a barcode) part: Link to the master abstract part that this StockItem is an instance of supplier_part: Link to a specific SupplierPart (optional) location: Where this StockItem is located quantity: Number of stocked units batch: Batch number for this StockItem serial: Unique serial number for this StockItem link: Optional URL to link to external resource updated: Date that this stock item was last updated (auto) stocktake_date: Date of last stocktake for this item stocktake_user: User that performed the most recent stocktake review_needed: Flag if StockItem needs review delete_on_deplete: If True, StockItem will be deleted when the stock level gets to zero status: Status of this StockItem (ref: InvenTree.status_codes.StockStatus) notes: Extra notes field build: Link to a Build (if this stock item was created from a build) purchase_order: Link to a PurchaseOrder (if this stock item was created from a PurchaseOrder) infinite: If True this StockItem can never be exhausted sales_order: Link to a SalesOrder object (if the StockItem has been assigned to a SalesOrder) build_order: Link to a BuildOrder object (if the StockItem has been assigned to a BuildOrder) """ # A Query filter which will be re-used in multiple places to determine if a StockItem is actually "in stock" IN_STOCK_FILTER = Q( sales_order=None, build_order=None, belongs_to=None, customer=None, status__in=StockStatus.AVAILABLE_CODES ) def save(self, *args, **kwargs): """ Save this StockItem to the database. Performs a number of checks: - Unique serial number requirement - Adds a transaction note when the item is first created. """ self.validate_unique() self.clean() if not self.pk: # StockItem has not yet been saved add_note = True else: # StockItem has already been saved add_note = False user = kwargs.pop('user', None) add_note = add_note and kwargs.pop('note', True) super(StockItem, self).save(*args, **kwargs) if add_note: # This StockItem is being saved for the first time self.addTransactionNote( 'Created stock item', user, notes="Created new stock item for part '{p}'".format(p=str(self.part)), system=True ) @property def status_label(self): return StockStatus.label(self.status) @property def serialized(self): """ Return True if this StockItem is serialized """ return self.serial is not None and self.quantity == 1 def validate_unique(self, exclude=None): """ Test that this StockItem is "unique". If the StockItem is serialized, the same serial number. cannot exist for the same part (or part tree). """ super(StockItem, self).validate_unique(exclude) if self.serial is not None: # Query to look for duplicate serial numbers parts = PartModels.Part.objects.filter(tree_id=self.part.tree_id) stock = StockItem.objects.filter(part__in=parts, serial=self.serial) # Exclude myself from the search if self.pk is not None: stock = stock.exclude(pk=self.pk) if stock.exists(): raise ValidationError({"serial": _("StockItem with this serial number already exists")}) def clean(self): """ Validate the StockItem object (separate to field validation) The following validation checks are performed: - The 'part' and 'supplier_part.part' fields cannot point to the same Part object - The 'part' does not belong to itself - Quantity must be 1 if the StockItem has a serial number """ super().clean() try: if self.part.trackable: # Trackable parts must have integer values for quantity field! if not self.quantity == int(self.quantity): raise ValidationError({ 'quantity': _('Quantity must be integer value for trackable parts') }) except PartModels.Part.DoesNotExist: # For some reason the 'clean' process sometimes throws errors because self.part does not exist # It *seems* that this only occurs in unit testing, though. # Probably should investigate this at some point. pass if self.quantity < 0: raise ValidationError({ 'quantity': _('Quantity must be greater than zero') }) # The 'supplier_part' field must point to the same part! try: if self.supplier_part is not None: if not self.supplier_part.part == self.part: raise ValidationError({'supplier_part': _("Part type ('{pf}') must be {pe}").format( pf=str(self.supplier_part.part), pe=str(self.part)) }) if self.part is not None: # A part with a serial number MUST have the quantity set to 1 if self.serial is not None: if self.quantity > 1: raise ValidationError({ 'quantity': _('Quantity must be 1 for item with a serial number'), 'serial': _('Serial number cannot be set if quantity greater than 1') }) if self.quantity == 0: self.quantity = 1 elif self.quantity > 1: raise ValidationError({ 'quantity': _('Quantity must be 1 for item with a serial number') }) # Serial numbered items cannot be deleted on depletion self.delete_on_deplete = False except PartModels.Part.DoesNotExist: # This gets thrown if self.supplier_part is null # TODO - Find a test than can be perfomed... pass if self.belongs_to and self.belongs_to.pk == self.pk: raise ValidationError({ 'belongs_to': _('Item cannot belong to itself') }) def get_absolute_url(self): return reverse('stock-item-detail', kwargs={'pk': self.id}) def get_part_name(self): return self.part.full_name def format_barcode(self, **kwargs): """ Return a JSON string for formatting a barcode for this StockItem. Can be used to perform lookup of a stockitem using barcode Contains the following data: { type: 'StockItem', stock_id: <pk>, part_id: <part_pk> } Voltagile data (e.g. stock quantity) should be looked up using the InvenTree API (as it may change) """ return helpers.MakeBarcode( "stockitem", self.id, { "url": reverse('api-stock-detail', kwargs={'pk': self.id}), }, **kwargs ) uid = models.CharField(blank=True, max_length=128, help_text=("Unique identifier field")) parent = TreeForeignKey( 'self', verbose_name=_('Parent Stock Item'), on_delete=models.DO_NOTHING, blank=True, null=True, related_name='children' ) part = models.ForeignKey( 'part.Part', on_delete=models.CASCADE, verbose_name=_('Base Part'), related_name='stock_items', help_text=_('Base part'), limit_choices_to={ 'active': True, 'virtual': False }) supplier_part = models.ForeignKey( 'company.SupplierPart', blank=True, null=True, on_delete=models.SET_NULL, verbose_name=_('Supplier Part'), help_text=_('Select a matching supplier part for this stock item') ) location = TreeForeignKey( StockLocation, on_delete=models.DO_NOTHING, verbose_name=_('Stock Location'), related_name='stock_items', blank=True, null=True, help_text=_('Where is this stock item located?') ) belongs_to = models.ForeignKey( 'self', verbose_name=_('Installed In'), on_delete=models.DO_NOTHING, related_name='owned_parts', blank=True, null=True, help_text=_('Is this item installed in another item?') ) customer = models.ForeignKey( CompanyModels.Company, on_delete=models.SET_NULL, null=True, blank=True, limit_choices_to={'is_customer': True}, related_name='assigned_stock', help_text=_("Customer"), verbose_name=_("Customer"), ) serial = models.CharField( verbose_name=_('Serial Number'), max_length=100, blank=True, null=True, help_text=_('Serial number for this item') ) link = InvenTreeURLField( verbose_name=_('External Link'), max_length=125, blank=True, help_text=_("Link to external URL") ) batch = models.CharField( verbose_name=_('Batch Code'), max_length=100, blank=True, null=True, help_text=_('Batch code for this stock item') ) quantity = models.DecimalField( verbose_name=_("Stock Quantity"), max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1 ) updated = models.DateField(auto_now=True, null=True) build = models.ForeignKey( 'build.Build', on_delete=models.SET_NULL, verbose_name=_('Source Build'), blank=True, null=True, help_text=_('Build for this stock item'), related_name='build_outputs', ) purchase_order = models.ForeignKey( 'order.PurchaseOrder', on_delete=models.SET_NULL, verbose_name=_('Source Purchase Order'), related_name='stock_items', blank=True, null=True, help_text=_('Purchase order for this stock item') ) sales_order = models.ForeignKey( 'order.SalesOrder', on_delete=models.SET_NULL, verbose_name=_("Destination Sales Order"), related_name='stock_items', null=True, blank=True) build_order = models.ForeignKey( 'build.Build', on_delete=models.SET_NULL, verbose_name=_("Destination Build Order"), related_name='stock_items', null=True, blank=True ) # last time the stock was checked / counted stocktake_date = models.DateField(blank=True, null=True) stocktake_user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, related_name='stocktake_stock') review_needed = models.BooleanField(default=False) delete_on_deplete = models.BooleanField(default=True, help_text=_('Delete this Stock Item when stock is depleted')) status = models.PositiveIntegerField( default=StockStatus.OK, choices=StockStatus.items(), validators=[MinValueValidator(0)]) notes = MarkdownxField( blank=True, null=True, verbose_name=_("Notes"), help_text=_('Stock Item Notes') ) def clearAllocations(self): """ Clear all order allocations for this StockItem: - SalesOrder allocations - Build allocations """ # Delete outstanding SalesOrder allocations self.sales_order_allocations.all().delete() # Delete outstanding BuildOrder allocations self.allocations.all().delete() def allocateToCustomer(self, customer, quantity=None, order=None, user=None, notes=None): """ Allocate a StockItem to a customer. This action can be called by the following processes: - Completion of a SalesOrder - User manually assigns a StockItem to the customer Args: customer: The customer (Company) to assign the stock to quantity: Quantity to assign (if not supplied, total quantity is used) order: SalesOrder reference user: User that performed the action notes: Notes field """ if quantity is None: quantity = self.quantity if quantity >= self.quantity: item = self else: item = self.splitStock(quantity, None, user) # Update StockItem fields with new information item.sales_order = order item.customer = customer item.location = None item.save() # TODO - Remove any stock item allocations from this stock item item.addTransactionNote( _("Assigned to Customer"), user, notes=_("Manually assigned to customer") + " " + customer.name, system=True ) # Return the reference to the stock item return item def returnFromCustomer(self, location, user=None): """ Return stock item from customer, back into the specified location. """ self.addTransactionNote( _("Returned from customer") + " " + self.customer.name, user, notes=_("Returned to location") + " " + location.name, system=True ) self.customer = None self.location = location self.save() # If stock item is incoming, an (optional) ETA field # expected_arrival = models.DateField(null=True, blank=True) infinite = models.BooleanField(default=False) def is_allocated(self): """ Return True if this StockItem is allocated to a SalesOrder or a Build """ # TODO - For now this only checks if the StockItem is allocated to a SalesOrder # TODO - In future, once the "build" is working better, check this too if self.allocations.count() > 0: return True if self.sales_order_allocations.count() > 0: return True return False def build_allocation_count(self): """ Return the total quantity allocated to builds """ query = self.allocations.aggregate(q=Coalesce(Sum('quantity'), Decimal(0))) return query['q'] def sales_order_allocation_count(self): """ Return the total quantity allocated to SalesOrders """ query = self.sales_order_allocations.aggregate(q=Coalesce(Sum('quantity'), Decimal(0))) return query['q'] def allocation_count(self): """ Return the total quantity allocated to builds or orders """ return self.build_allocation_count() + self.sales_order_allocation_count() def unallocated_quantity(self): """ Return the quantity of this StockItem which is *not* allocated """ return max(self.quantity - self.allocation_count(), 0) def can_delete(self): """ Can this stock item be deleted? It can NOT be deleted under the following circumstances: - Has child StockItems - Has a serial number and is tracked - Is installed inside another StockItem - It has been assigned to a SalesOrder - It has been assigned to a BuildOrder """ if self.child_count > 0: return False if self.part.trackable and self.serial is not None: return False if self.sales_order is not None: return False if self.build_order is not None: return False return True @property def children(self): """ Return a list of the child items which have been split from this stock item """ return self.get_descendants(include_self=False) @property def child_count(self): """ Return the number of 'child' items associated with this StockItem. A child item is one which has been split from this one. """ return self.children.count() @property def in_stock(self): # Not 'in stock' if it has been installed inside another StockItem if self.belongs_to is not None: return False # Not 'in stock' if it has been sent to a customer if self.sales_order is not None: return False # Not 'in stock' if it has been allocated to a BuildOrder if self.build_order is not None: return False # Not 'in stock' if it has been assigned to a customer if self.customer is not None: return False # Not 'in stock' if the status code makes it unavailable if self.status in StockStatus.UNAVAILABLE_CODES: return False return True @property def tracking_info_count(self): return self.tracking_info.count() @property def has_tracking_info(self): return self.tracking_info_count > 0 def addTransactionNote(self, title, user, notes='', url='', system=True): """ Generation a stock transaction note for this item. Brief automated note detailing a movement or quantity change. """ track = StockItemTracking.objects.create( item=self, title=title, user=user, quantity=self.quantity, date=datetime.now().date(), notes=notes, link=url, system=system ) track.save() @transaction.atomic def serializeStock(self, quantity, serials, user, notes='', location=None): """ Split this stock item into unique serial numbers. - Quantity can be less than or equal to the quantity of the stock item - Number of serial numbers must match the quantity - Provided serial numbers must not already be in use Args: quantity: Number of items to serialize (integer) serials: List of serial numbers (list<int>) user: User object associated with action notes: Optional notes for tracking location: If specified, serialized items will be placed in the given location """ # Cannot serialize stock that is already serialized! if self.serialized: return if not self.part.trackable: raise ValidationError({"part": _("Part is not set as trackable")}) # Quantity must be a valid integer value try: quantity = int(quantity) except ValueError: raise ValidationError({"quantity": _("Quantity must be integer")}) if quantity <= 0: raise ValidationError({"quantity": _("Quantity must be greater than zero")}) if quantity > self.quantity: raise ValidationError({"quantity": _("Quantity must not exceed available stock quantity ({n})".format(n=self.quantity))}) if not type(serials) in [list, tuple]: raise ValidationError({"serial_numbers": _("Serial numbers must be a list of integers")}) if not quantity == len(serials): raise ValidationError({"quantity": _("Quantity does not match serial numbers")}) # Test if each of the serial numbers are valid existing = [] for serial in serials: if self.part.checkIfSerialNumberExists(serial): existing.append(serial) if len(existing) > 0: raise ValidationError({"serial_numbers": _("Serial numbers already exist: ") + str(existing)}) # Create a new stock item for each unique serial number for serial in serials: # Create a copy of this StockItem new_item = StockItem.objects.get(pk=self.pk) new_item.quantity = 1 new_item.serial = serial new_item.pk = None new_item.parent = self if location: new_item.location = location # The item already has a transaction history, don't create a new note new_item.save(user=user, note=False) # Copy entire transaction history new_item.copyHistoryFrom(self) # Copy test result history new_item.copyTestResultsFrom(self) # Create a new stock tracking item new_item.addTransactionNote(_('Add serial number'), user, notes=notes) # Remove the equivalent number of items self.take_stock(quantity, user, notes=_('Serialized {n} items'.format(n=quantity))) @transaction.atomic def copyHistoryFrom(self, other): """ Copy stock history from another StockItem """ for item in other.tracking_info.all(): item.item = self item.pk = None item.save() @transaction.atomic def copyTestResultsFrom(self, other, filters={}): """ Copy all test results from another StockItem """ for result in other.test_results.all().filter(**filters): # Create a copy of the test result by nulling-out the pk result.pk = None result.stock_item = self result.save() @transaction.atomic def splitStock(self, quantity, location, user): """ Split this stock item into two items, in the same location. Stock tracking notes for this StockItem will be duplicated, and added to the new StockItem. Args: quantity: Number of stock items to remove from this entity, and pass to the next location: Where to move the new StockItem to Notes: The provided quantity will be subtracted from this item and given to the new one. The new item will have a different StockItem ID, while this will remain the same. """ # Do not split a serialized part if self.serialized: return try: quantity = Decimal(quantity) except (InvalidOperation, ValueError): return # Doesn't make sense for a zero quantity if quantity <= 0: return # Also doesn't make sense to split the full amount if quantity >= self.quantity: return # Create a new StockItem object, duplicating relevant fields # Nullify the PK so a new record is created new_stock = StockItem.objects.get(pk=self.pk) new_stock.pk = None new_stock.parent = self new_stock.quantity = quantity # Move to the new location if specified, otherwise use current location if location: new_stock.location = location else: new_stock.location = self.location new_stock.save() # Copy the transaction history of this part into the new one new_stock.copyHistoryFrom(self) # Copy the test results of this part to the new one new_stock.copyTestResultsFrom(self) # Add a new tracking item for the new stock item new_stock.addTransactionNote( "Split from existing stock", user, "Split {n} from existing stock item".format(n=quantity)) # Remove the specified quantity from THIS stock item self.take_stock(quantity, user, 'Split {n} items into new stock item'.format(n=quantity)) # Return a copy of the "new" stock item return new_stock @transaction.atomic def move(self, location, notes, user, **kwargs): """ Move part to a new location. If less than the available quantity is to be moved, a new StockItem is created, with the defined quantity, and that new StockItem is moved. The quantity is also subtracted from the existing StockItem. Args: location: Destination location (cannot be null) notes: User notes user: Who is performing the move kwargs: quantity: If provided, override the quantity (default = total stock quantity) """ try: quantity = Decimal(kwargs.get('quantity', self.quantity)) except InvalidOperation: return False if not self.in_stock: raise ValidationError(_("StockItem cannot be moved as it is not in stock")) if quantity <= 0: return False if location is None: # TODO - Raise appropriate error (cannot move to blank location) return False elif self.location and (location.pk == self.location.pk) and (quantity == self.quantity): # TODO - Raise appropriate error (cannot move to same location) return False # Test for a partial movement if quantity < self.quantity: # We need to split the stock! # Split the existing StockItem in two self.splitStock(quantity, location, user) return True msg = "Moved to {loc}".format(loc=str(location)) if self.location: msg += " (from {loc})".format(loc=str(self.location)) self.location = location self.addTransactionNote( msg, user, notes=notes, system=True) self.save() return True @transaction.atomic def updateQuantity(self, quantity): """ Update stock quantity for this item. If the quantity has reached zero, this StockItem will be deleted. Returns: - True if the quantity was saved - False if the StockItem was deleted """ # Do not adjust quantity of a serialized part if self.serialized: return try: self.quantity = Decimal(quantity) except (InvalidOperation, ValueError): return if quantity < 0: quantity = 0 self.quantity = quantity if quantity == 0 and self.delete_on_deplete and self.can_delete(): # TODO - Do not actually "delete" stock at this point - instead give it a "DELETED" flag self.delete() return False else: self.save() return True @transaction.atomic def stocktake(self, count, user, notes=''): """ Perform item stocktake. When the quantity of an item is counted, record the date of stocktake """ try: count = Decimal(count) except InvalidOperation: return False if count < 0 or self.infinite: return False self.stocktake_date = datetime.now().date() self.stocktake_user = user if self.updateQuantity(count): self.addTransactionNote('Stocktake - counted {n} items'.format(n=count), user, notes=notes, system=True) return True @transaction.atomic def add_stock(self, quantity, user, notes=''): """ Add items to stock This function can be called by initiating a ProjectRun, or by manually adding the items to the stock location """ # Cannot add items to a serialized part if self.serialized: return False try: quantity = Decimal(quantity) except InvalidOperation: return False # Ignore amounts that do not make sense if quantity <= 0 or self.infinite: return False if self.updateQuantity(self.quantity + quantity): self.addTransactionNote('Added {n} items to stock'.format(n=quantity), user, notes=notes, system=True) return True @transaction.atomic def take_stock(self, quantity, user, notes=''): """ Remove items from stock """ # Cannot remove items from a serialized part if self.serialized: return False try: quantity = Decimal(quantity) except InvalidOperation: return False if quantity <= 0 or self.infinite: return False if self.updateQuantity(self.quantity - quantity): self.addTransactionNote('Removed {n} items from stock'.format(n=quantity), user, notes=notes, system=True) return True def __str__(self): if self.part.trackable and self.serial: s = '{part} #{sn}'.format( part=self.part.full_name, sn=self.serial) else: s = '{n} x {part}'.format( n=helpers.decimal2string(self.quantity), part=self.part.full_name) if self.location: s += ' @ {loc}'.format(loc=self.location.name) return s def getTestResults(self, test=None, result=None, user=None): """ Return all test results associated with this StockItem. Optionally can filter results by: - Test name - Test result - User """ results = self.test_results if test: # Filter by test name results = results.filter(test=test) if result is not None: # Filter by test status results = results.filter(result=result) if user: # Filter by user results = results.filter(user=user) return results def testResultMap(self, **kwargs): """ Return a map of test-results using the test name as the key. Where multiple test results exist for a given name, the *most recent* test is used. This map is useful for rendering to a template (e.g. a test report), as all named tests are accessible. """ results = self.getTestResults(**kwargs).order_by('-date') result_map = {} for result in results: key = helpers.generateTestKey(result.test) result_map[key] = result return result_map def testResultList(self, **kwargs): """ Return a list of test-result objects for this StockItem """ return self.testResultMap(**kwargs).values() def requiredTestStatus(self): """ Return the status of the tests required for this StockItem. return: A dict containing the following items: - total: Number of required tests - passed: Number of tests that have passed - failed: Number of tests that have failed """ # All the tests required by the part object required = self.part.getRequiredTests() results = self.testResultMap() total = len(required) passed = 0 failed = 0 for test in required: key = helpers.generateTestKey(test.test_name) if key in results: result = results[key] if result.result: passed += 1 else: failed += 1 return { 'total': total, 'passed': passed, 'failed': failed, } @property def required_test_count(self): return self.part.getRequiredTests().count() def hasRequiredTests(self): return self.part.getRequiredTests().count() > 0 def passedAllRequiredTests(self): status = self.requiredTestStatus() return status['passed'] >= status['total']
class Workshop(models.Model): title = models.CharField(max_length=300) dedicated_qiime2 = models.BooleanField(default=False) location = models.CharField(max_length=300) description = MarkdownxField() email_description = MarkdownxField(help_text='This is the text that is ' 'emailed to all workshop attendees ' 'when their payment is processed. ' 'Supports Markdown.', blank=True) start_date = models.DateField() end_date = models.DateField() url = models.URLField(verbose_name='URL', max_length=2000, blank=True) slug = models.SlugField(help_text='This is the unique identifier for the ' 'URL (i.e. title-YYYY-MM-DD)') draft = models.BooleanField(help_text='Draft workshops do not show up on ' 'the workshop list overview', default=True) @property def total_tickets_sold(self): return OrderItem.objects.filter(rate__workshop=self) \ .exclude(order__billed_total='') \ .exclude(order__refunded=True).count() @property def is_open(self): return self.rate_set.filter( private=False, sold_out=False, sales_open=True).count() != 0 @property def available_rates(self): return self.rate_set.filter(sold_out=False) @property def sold_out_rates(self): return self.rate_set.filter(sold_out=True) class Meta: unique_together = (('title', 'slug'), ) def clean(self): # Make sure the workshop begins before it can end... if self.start_date > self.end_date: raise ValidationError('A Workshop\'s start date must be before ' 'the end date.') return super().clean() def __str__(self): return self.title # For django admin 'view on site' link def get_absolute_url(self): return reverse('payments:details', kwargs={'slug': self.slug}, subdomain='workshops') def filter_rates(self, rate_code): rate_set = None if rate_code: rate_set = self.rate_set.filter(discount_code=rate_code, sales_open=True).order_by('price') if rate_code is None or len(rate_set) == 0: rate_set = self.rate_set.filter(private=False, sales_open=True).order_by('price') return rate_set
class RequirementModel(models.Model): Requirement = MarkdownxField()
class SalesOrderShipment(models.Model): """ The SalesOrderShipment model represents a physical shipment made against a SalesOrder. - Points to a single SalesOrder object - Multiple SalesOrderAllocation objects point to a particular SalesOrderShipment - When a given SalesOrderShipment is "shipped", stock items are removed from stock Attributes: order: SalesOrder reference shipment_date: Date this shipment was "shipped" (or null) checked_by: User reference field indicating who checked this order reference: Custom reference text for this shipment (e.g. consignment number?) notes: Custom notes field for this shipment """ class Meta: # Shipment reference must be unique for a given sales order unique_together = [ 'order', 'reference', ] @staticmethod def get_api_url(): return reverse('api-so-shipment-list') order = models.ForeignKey( SalesOrder, on_delete=models.CASCADE, blank=False, null=False, related_name='shipments', verbose_name=_('Order'), help_text=_('Sales Order'), ) shipment_date = models.DateField( null=True, blank=True, verbose_name=_('Shipment Date'), help_text=_('Date of shipment'), ) checked_by = models.ForeignKey( User, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('Checked By'), help_text=_('User who checked this shipment'), related_name='+', ) reference = models.CharField( max_length=100, blank=False, verbose_name=('Shipment'), help_text=_('Shipment number'), default='1', ) notes = MarkdownxField( blank=True, verbose_name=_('Notes'), help_text=_('Shipment notes'), ) tracking_number = models.CharField( max_length=100, blank=True, unique=False, verbose_name=_('Tracking Number'), help_text=_('Shipment tracking information'), ) invoice_number = models.CharField( max_length=100, blank=True, unique=False, verbose_name=_('Invoice Number'), help_text=_('Reference number for associated invoice'), ) link = models.URLField(blank=True, verbose_name=_('Link'), help_text=_('Link to external page')) def is_complete(self): return self.shipment_date is not None def check_can_complete(self, raise_error=True): try: if self.shipment_date: # Shipment has already been sent! raise ValidationError(_("Shipment has already been sent")) if self.allocations.count() == 0: raise ValidationError( _("Shipment has no allocated stock items")) except ValidationError as e: if raise_error: raise e else: return False return True @transaction.atomic def complete_shipment(self, user, **kwargs): """ Complete this particular shipment: 1. Update any stock items associated with this shipment 2. Update the "shipped" quantity of all associated line items 3. Set the "shipment_date" to now """ # Check if the shipment can be completed (throw error if not) self.check_can_complete() allocations = self.allocations.all() # Iterate through each stock item assigned to this shipment for allocation in allocations: # Mark the allocation as "complete" allocation.complete_allocation(user) # Update the "shipment" date self.shipment_date = kwargs.get('shipment_date', datetime.now()) self.shipped_by = user # Was a tracking number provided? tracking_number = kwargs.get('tracking_number', None) if tracking_number is not None: self.tracking_number = tracking_number # Was an invoice number provided? invoice_number = kwargs.get('invoice_number', None) if invoice_number is not None: self.invoice_number = invoice_number # Was a link provided? link = kwargs.get('link', None) if link is not None: self.link = link self.save() trigger_event('salesordershipment.completed', id=self.pk)
class NewsItem(djm.Model): title = djm.CharField(max_length=256) summary = djm.TextField(max_length=300) content = MarkdownxField() datetime = djm.DateTimeField() edited = djm.BooleanField(default=False) edit_note = djm.CharField(null=True, blank=True, default=None, max_length=256) edit_datetime = djm.DateTimeField(null=True, blank=True, default=None) author = djm.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, on_delete=djm.SET_NULL, related_name='authored_news_items') edit_author = djm.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=djm.SET_NULL, related_name='edited_news_items') tags = djm.ManyToManyField(NewsTag, blank=True) source = djm.URLField(null=True, blank=True, max_length=256) cover_img = djm.ImageField(null=True, blank=True, max_length=256, upload_to=news_item_picture) cover_thumbnail = ImageSpecField( source='cover_img', processors=[SmartResize(*settings.MEDIUM_ICON_SIZE)], format='JPEG', options={'quality': settings.MEDIUM_QUALITY}) generated = djm.BooleanField(default=True) class Meta: unique_together = ['title', 'datetime'] ordering = ['datetime', 'title'] def __str__(self): return self.title def get_absolute_url(self): return reverse('news:item', args=[self.id]) @property def content_html(self): return markdownify(self.content) def gen_summary(self): html = markdownify(self.content) soup = BeautifulSoup(html, 'html.parser') content = soup.text[:300] if len(content) < 300: summary = content else: summary = content.rsplit(' ', 1)[0] + " ..." if summary != self.summary: self.summary = summary @property def thumbnail_or_default(self): if self.cover_img: return self.cover_thumbnail.url
class Order(models.Model): """ Abstract model for an order. Instances of this class: - PuchaseOrder Attributes: reference: Unique order number / reference / code description: Long form description (required) notes: Extra note field (optional) creation_date: Automatic date of order creation created_by: User who created this order (automatically captured) issue_date: Date the order was issued complete_date: Date the order was completed """ ORDER_PREFIX = "" @classmethod def getNextOrderNumber(cls): """ Try to predict the next order-number """ if cls.objects.count() == 0: return None # We will assume that the latest pk has the highest PO number order = cls.objects.last() ref = order.reference if not ref: return None tries = set() tries.add(ref) while 1: new_ref = increment(ref) if new_ref in tries: # We are in a looping situation - simply return the original one return ref # Check that the new ref does not exist in the database if cls.objects.filter(reference=new_ref).exists(): tries.add(new_ref) new_ref = increment(new_ref) else: break return new_ref def __str__(self): el = [] if self.ORDER_PREFIX: el.append(self.ORDER_PREFIX) el.append(self.reference) return " ".join(el) def save(self, *args, **kwargs): if not self.creation_date: self.creation_date = datetime.now().date() super().save(*args, **kwargs) class Meta: abstract = True reference = models.CharField(unique=True, max_length=64, blank=False, help_text=_('Order reference')) description = models.CharField(max_length=250, help_text=_('Order description')) link = models.URLField(blank=True, help_text=_('Link to external page')) creation_date = models.DateField(blank=True, null=True) created_by = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, related_name='+') notes = MarkdownxField(blank=True, help_text=_('Order notes'))
class Table(models.Model): objects = TableQuerySet.as_manager() dataset = models.ForeignKey(Dataset, on_delete=models.CASCADE, null=False, blank=False) default = models.BooleanField(null=False, blank=False) name = models.CharField(max_length=255, null=False, blank=False) options = JSONField(null=True, blank=True) ordering = ArrayField(models.CharField(max_length=63), null=False, blank=False) filtering = ArrayField(models.CharField(max_length=63), null=True, blank=True) search = ArrayField(models.CharField(max_length=63), null=True, blank=True) version = models.ForeignKey(Version, on_delete=models.CASCADE, null=False, blank=False) import_date = models.DateTimeField(null=True, blank=True) description = MarkdownxField(null=True, blank=True) def __str__(self): return "{}.{}.{}".format(self.dataset.slug, self.version.name, self.name) @property def db_table(self): return "data_{}_{}".format( self.dataset.slug.replace("-", ""), self.name.replace("_", ""), ) @property def fields(self): return self.field_set.all() @property def schema(self): db_fields_to_rows_fields = { "binary": rows_fields.BinaryField, "bool": rows_fields.BoolField, "date": rows_fields.DateField, "datetime": rows_fields.DatetimeField, "decimal": rows_fields.DecimalField, "email": rows_fields.EmailField, "float": rows_fields.FloatField, "integer": rows_fields.IntegerField, "json": rows_fields.JSONField, "string": rows_fields.TextField, "text": rows_fields.TextField, } return OrderedDict([ (n, db_fields_to_rows_fields.get(t, rows_fields.Field)) for n, t in self.fields.values_list("name", "type") ]) def get_model(self, cache=True): if cache and self.id in DYNAMIC_MODEL_REGISTRY: return DYNAMIC_MODEL_REGISTRY[self.id] # TODO: unregister the model in Django if already registered (self.id # in DYNAMIC_MODEL_REGISTRY and not cache) # TODO: may use Django's internal registry instead of # DYNAMIC_MODEL_REGISTRY name = self.dataset.slug + "-" + self.name.replace("_", "-") model_name = "".join([word.capitalize() for word in name.split("-")]) fields = {field.name: field.field_class for field in self.fields} fields["search_data"] = SearchVectorField(null=True) ordering = self.ordering or [] filtering = self.filtering or [] search = self.search or [] indexes = [] # TODO: add has_choices fields also if ordering: indexes.append( django_indexes.Index( name=make_index_name(name, "order", ordering), fields=ordering, )) if filtering: for field_name in filtering: if ordering == [field_name]: continue indexes.append( django_indexes.Index(name=make_index_name( name, "filter", [field_name]), fields=[field_name])) if search: indexes.append( pg_indexes.GinIndex(name=make_index_name( name, "search", ["search_data"]), fields=["search_data"])) Options = type( "Meta", (object, ), { "ordering": ordering, "indexes": indexes, "db_table": self.db_table, }, ) Model = type( model_name, ( DynamicModelMixin, models.Model, ), { "__module__": "core.models", "Meta": Options, "objects": DynamicModelQuerySet.as_manager(), **fields, }, ) Model.extra = { "filtering": filtering, "ordering": ordering, "search": search, } DYNAMIC_MODEL_REGISTRY[self.id] = Model return Model def get_model_declaration(self): Model = self.get_model() return model_to_code(Model) def invalidate_cache(self): invalidate(self.db_table)
class Comment(models.Model): post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='comments', verbose_name='Пост') parent = models.ForeignKey('self', on_delete=models.CASCADE, blank=True, null=True, verbose_name='Родитель') name = models.CharField(max_length=30, verbose_name='Имя') body = MarkdownxField(max_length=3000, blank=False, db_index=False, verbose_name='Текст') created_on = models.DateTimeField(auto_now_add=True, verbose_name='Дата') mark = models.BooleanField(default=False, verbose_name='Маркер') def children(self): # replies return Comment.objects.filter(parent=self) @property def is_parent(self): if self.parent is not None: return False return True @staticmethod def current_year(): return str(datetime.datetime.now().year) def formatted_markdown(self): return bleach.clean(markdownify(self.body), markdown_tags, markdown_attrs) @staticmethod def gen_avatar(): user_avatar_id = randint(1, 16) return "{}.jpg".format(user_avatar_id) def get_absolute_url(self): return reverse('post_detail_url', kwargs={ 'slug': self.post.slug }) + "#comment{}".format(self.id) def get_delete_url(self): return reverse('comment_delete_url', kwargs={ 'slug': self.post.slug, 'id': self.id }) def get_api_url(self): return 'https://warkentin.ru' + reverse( 'post_detail_api_url', kwargs={'slug': self.post.slug}) class Meta: ordering = ['created_on'] def __str__(self): return '{} пишет: «{}»'.format(self.name, self.body)
class Model_Markdown(models.Model): markdown = MarkdownxField()
class Post(models.Model): title = models.CharField(max_length=150, verbose_name='Заголовок') slug = models.SlugField(max_length=150, blank=True, unique=True, verbose_name='УРЛ') body = MarkdownxField(blank=True, db_index=False, verbose_name='Текст') tags = models.ManyToManyField('Tag', blank=True, related_name='posts', verbose_name='Теги') reading_time = models.PositiveSmallIntegerField( default=0, verbose_name='Время чтения') og_image = models.URLField( default= "https://res.cloudinary.com/wark/image/upload/v1582458310/og_default.jpg", verbose_name='Картинка для соцсетей') allow_comments = models.BooleanField(default=True, verbose_name='Открытые комментарии') date_pub = models.DateTimeField(auto_now_add=True) def formatted_markdown(self): return markdownify(self.body) def get_absolute_url(self): return reverse('post_detail_url', kwargs={'slug': self.slug}) def get_update_url(self): return reverse('post_update_url', kwargs={'slug': self.slug}) def get_delete_url(self): return reverse('post_delete_url', kwargs={'slug': self.slug}) def get_comments_without_replies(self): return Comment.objects.filter(post_id=self.id, parent_id=None) def get_replies(self): return Comment.objects.filter(post_id=self.id).exclude(parent_id=None) def get_reading_time(self): # convert markdown text to html code article_html = markdownify(self.body) # count words world_string = strip_tags(article_html) matching_words = re.findall(r'\w+', world_string) count = len(matching_words) # reading time # assuming 200wpm reading reading_time_min = math.ceil(count / 200.0) return int(reading_time_min) def save(self, *args, **kwargs): if self.body: self.reading_time = self.get_reading_time() if not self.slug: new_slug = slugify(self.title, allow_unicode=True) self.slug = new_slug + '-' + str( int(datetime.datetime.now().microsecond)) super().save(*args, **kwargs) @staticmethod def current_year(): return str(datetime.datetime.now().year) def __str__(self): return self.title class Meta: ordering = ['-date_pub']
class Answer(models.Model): uuid_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="a_author", verbose_name="问题回答者") question = models.ForeignKey(Question, on_delete=models.CASCADE, verbose_name="") content = MarkdownxField(verbose_name="回答的特容") is_answer = models.BooleanField(default=False, verbose_name="回答是否呗接受") votes = GenericRelation(Vote, verbose_name='投票情况') created_at = models.DateTimeField(db_index=True, auto_now_add=True, verbose_name='创建时间') updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间') class Meta: verbose_name = "回答" verbose_name_plural = verbose_name # 多字段排序 ordering = ('-is_answer', '-created_at') def __str__(self): return self.content def get_markdown(self): return markdownify(self.content) def total_votes(self): """总票数""" # self.votes.value_list 相当于 Vote.objects.value_list() dic = Counter(Vote.objects.values_list("value", flat=True)) return dic[True] - dic[False] def get_upvoters(self): """赞同用户""" # return [vote.user for vote in self.votes.filter(value=True)] return [ vote.user for vote in self.votes.filter( value=True).select_related('user').prefetch_related('vote') ] def get_downvoters(self): """踩的用户""" # return [vote.user for vote in self.votes.filter(value=False)] return [ vote.user for vote in self.votes.filter( value=False).select_related('user').prefetch_related('vote') ] # 获取问题接受的回答 def get_accepted_answer(self): return Answer.objects.get(question=self, is_answer=True) # 采纳回答 def accept_answer(self): # 当一个问题有多个回答时,只接受一个答案,其他答案不接受 answer_set = Answer.objects.filter(question=self.question) # 查询当前所有答案 answer_set.update(is_answer=False) # 一律设为未接受 # 接受当前回答并保存 self.is_answer = True self.save() # 该问题已有呗接受的答案 self.question.has_answer = True self.question.save()
class PackageItem(ActiveModel): description = MarkdownxField() def __str__(self): return self.description[0:100]
class Part(models.Model): """ The Part object represents an abstract part, the 'concept' of an actual entity. An actual physical instance of a Part is a StockItem which is treated separately. Parts can be used to create other parts (as part of a Bill of Materials or BOM). Attributes: name: Brief name for this part variant: Optional variant number for this part - Must be unique for the part name category: The PartCategory to which this part belongs description: Longer form description of the part keywords: Optional keywords for improving part search results IPN: Internal part number (optional) revision: Part revision is_template: If True, this part is a 'template' part and cannot be instantiated as a StockItem link: Link to an external page with more information about this part (e.g. internal Wiki) image: Image of this part default_location: Where the item is normally stored (may be null) default_supplier: The default SupplierPart which should be used to procure and stock this part minimum_stock: Minimum preferred quantity to keep in stock units: Units of measure for this part (default='pcs') salable: Can this part be sold to customers? assembly: Can this part be build from other parts? component: Can this part be used to make other parts? purchaseable: Can this part be purchased from suppliers? trackable: Trackable parts can have unique serial numbers assigned, etc, etc active: Is this part active? Parts are deactivated instead of being deleted virtual: Is this part "virtual"? e.g. a software product or similar notes: Additional notes field for this part creation_date: Date that this part was added to the database creation_user: User who added this part to the database responsible: User who is responsible for this part (optional) """ class Meta: verbose_name = "Part" verbose_name_plural = "Parts" 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) def __str__(self): return "{n} - {d}".format(n=self.full_name, d=self.description) @property def full_name(self): """ Format a 'full name' for this Part. - IPN (if not null) - Part name - Part variant (if not null) Elements are joined by the | character """ elements = [] if self.IPN: elements.append(self.IPN) elements.append(self.name) if self.revision: elements.append(self.revision) return ' | '.join(elements) def set_category(self, category): # Ignore if the category is already the same if self.category == category: return self.category = category self.save() def get_absolute_url(self): """ Return the web URL for viewing this part """ return reverse('part-detail', kwargs={'pk': self.id}) def get_image_url(self): """ Return the URL of the image for this part """ if self.image: return helpers.getMediaUrl(self.image.url) else: return helpers.getBlankImage() def get_thumbnail_url(self): """ Return the URL of the image thumbnail for this part """ if self.image: return helpers.getMediaUrl(self.image.thumbnail.url) else: return helpers.getBlankThumbnail() def validate_unique(self, exclude=None): """ Validate that a part is 'unique'. Uniqueness is checked across the following (case insensitive) fields: * Name * IPN * Revision e.g. there can exist multiple parts with the same name, but only if they have a different revision or internal part number. """ super().validate_unique(exclude) # Part name uniqueness should be case insensitive try: parts = Part.objects.exclude(id=self.id).filter( name__iexact=self.name, IPN__iexact=self.IPN, revision__iexact=self.revision) if parts.exists(): msg = _("Part must be unique for name, IPN and revision") raise ValidationError({ "name": msg, "IPN": msg, "revision": msg, }) except Part.DoesNotExist: pass def clean(self): """ Perform cleaning operations for the Part model """ if self.is_template and self.variant_of is not None: raise ValidationError({ 'is_template': _("Part cannot be a template part if it is a variant of another part" ), 'variant_of': _("Part cannot be a variant of another part if it is already a template" ), }) name = models.CharField(max_length=100, blank=False, help_text=_('Part name'), validators=[validators.validate_part_name]) is_template = models.BooleanField( default=False, help_text=_('Is this part a template part?')) variant_of = models.ForeignKey( 'part.Part', related_name='variants', null=True, blank=True, limit_choices_to={ 'is_template': True, 'active': True, }, on_delete=models.SET_NULL, help_text=_('Is this part a variant of another part?')) description = models.CharField(max_length=250, blank=False, help_text=_('Part description')) keywords = models.CharField( max_length=250, blank=True, help_text=_('Part keywords to improve visibility in search results')) category = TreeForeignKey(PartCategory, related_name='parts', null=True, blank=True, on_delete=models.DO_NOTHING, help_text=_('Part category')) IPN = models.CharField(max_length=100, blank=True, help_text=_('Internal Part Number'), validators=[validators.validate_part_ipn]) revision = models.CharField(max_length=100, blank=True, help_text=_('Part revision or version number')) link = InvenTreeURLField(blank=True, help_text=_('Link to extenal URL')) image = StdImageField( upload_to=rename_part_image, null=True, blank=True, variations={'thumbnail': (128, 128)}, delete_orphans=True, ) default_location = TreeForeignKey( 'stock.StockLocation', on_delete=models.SET_NULL, blank=True, null=True, help_text=_('Where is this item normally stored?'), related_name='default_parts') def get_default_location(self): """ Get the default location for a Part (may be None). If the Part does not specify a default location, look at the Category this part is in. The PartCategory object may also specify a default stock location """ if self.default_location: return self.default_location elif self.category: # Traverse up the category tree until we find a default location cats = self.category.get_ancestors(ascending=True, include_self=True) for cat in cats: if cat.default_location: return cat.default_location # Default case - no default category found return None def get_default_supplier(self): """ Get the default supplier part for this part (may be None). - If the part specifies a default_supplier, return that - If there is only one supplier part available, return that - Else, return None """ if self.default_supplier: return self.default_supplier if self.supplier_count == 1: return self.supplier_parts.first() # Default to None if there are multiple suppliers to choose from return None default_supplier = models.ForeignKey(SupplierPart, on_delete=models.SET_NULL, blank=True, null=True, help_text=_('Default supplier part'), related_name='default_parts') minimum_stock = models.PositiveIntegerField( default=0, validators=[MinValueValidator(0)], help_text=_('Minimum allowed stock level')) units = models.CharField(max_length=20, default="", blank=True, help_text=_('Stock keeping units for this part')) assembly = models.BooleanField( default=False, verbose_name='Assembly', help_text=_('Can this part be built from other parts?')) component = models.BooleanField( default=True, verbose_name='Component', help_text=_('Can this part be used to build other parts?')) trackable = models.BooleanField( default=False, help_text=_('Does this part have tracking for unique items?')) purchaseable = models.BooleanField( default=True, help_text=_('Can this part be purchased from external suppliers?')) salable = models.BooleanField( default=False, help_text=_("Can this part be sold to customers?")) active = models.BooleanField(default=True, help_text=_('Is this part active?')) virtual = models.BooleanField( default=False, help_text=_( 'Is this a virtual part, such as a software product or license?')) notes = MarkdownxField( blank=True, help_text=_('Part notes - supports Markdown formatting')) bom_checksum = models.CharField(max_length=128, blank=True, help_text=_('Stored BOM checksum')) 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) 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') def format_barcode(self): """ Return a JSON string for formatting a barcode for this Part object """ return helpers.MakeBarcode( "part", { "id": self.id, "name": self.full_name, "url": reverse('api-part-detail', kwargs={'pk': self.id}), }) @property def category_path(self): if self.category: return self.category.pathstring return '' @property def available_stock(self): """ Return the total available stock. - This subtracts stock which is already allocated to builds """ total = self.total_stock total -= self.allocation_count() return max(total, 0) @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) @property def net_stock(self): """ Return the 'net' stock. It takes into account: - Stock on hand (total_stock) - Stock on order (on_order) - Stock allocated (allocation_count) This number (unlike 'available_stock') can be negative. """ return self.total_stock - self.allocation_count() + self.on_order def 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 def need_to_restock(self): """ Return True if this part needs to be restocked (either by purchasing or building). If the allocated_stock exceeds the total_stock, then we need to restock. """ return (self.total_stock + self.on_order - self.allocation_count) < self.minimum_stock @property def can_build(self): """ Return the number of units that can be build with available stock """ # If this part does NOT have a BOM, result is simply the currently available stock if not self.has_bom: return 0 total = None # Calculate the minimum number of parts that can be built using each sub-part for item in self.bom_items.all().prefetch_related( 'sub_part__stock_items'): stock = item.sub_part.available_stock n = int(stock / item.quantity) if total is None or n < total: total = n return max(total, 0) @property def active_builds(self): """ Return a list of outstanding builds. Builds marked as 'complete' or 'cancelled' are ignored """ return self.builds.filter(status__in=BuildStatus.ACTIVE_CODES) @property def inactive_builds(self): """ Return a list of inactive builds """ return self.builds.exclude(status__in=BuildStatus.ACTIVE_CODES) @property def quantity_being_built(self): """ Return the current number of parts currently being built """ quantity = self.active_builds.aggregate( quantity=Sum('quantity'))['quantity'] if quantity is None: quantity = 0 return quantity def build_order_allocations(self): """ Return all 'BuildItem' objects which allocate this part to Build objects """ return BuildModels.BuildItem.objects.filter( stock_item__part__id=self.id) def build_order_allocation_count(self): """ Return the total amount of this part allocated to build orders """ query = self.build_order_allocations().aggregate( total=Coalesce(Sum('quantity'), 0)) return query['total'] def sales_order_allocations(self): """ Return all sales-order-allocation objects which allocate this part to a SalesOrder """ return OrderModels.SalesOrderAllocation.objects.filter( item__part__id=self.id) def sales_order_allocation_count(self): """ Return the tutal quantity of this part allocated to sales orders """ query = self.sales_order_allocations().aggregate( total=Coalesce(Sum('quantity'), 0)) return query['total'] def allocation_count(self): """ Return the total quantity of stock allocated for this part, against both build orders and sales orders. """ return sum([ self.build_order_allocation_count(), self.sales_order_allocation_count(), ]) @property def stock_entries(self): """ Return all 'in stock' items. To be in stock: - build_order is None - sales_order is None - belongs_to is None """ return self.stock_items.filter(StockModels.StockItem.IN_STOCK_FILTER) @property def total_stock(self): """ Return the total stock quantity for this part. Part may be stored in multiple locations """ if self.is_template: total = sum( [variant.total_stock for variant in self.variants.all()]) else: total = self.stock_entries.filter( status__in=StockStatus.AVAILABLE_CODES).aggregate( total=Sum('quantity'))['total'] if total: return total else: return Decimal(0) @property def has_bom(self): return self.bom_count > 0 @property def bom_count(self): """ Return the number of items contained in the BOM for this part """ return self.bom_items.count() @property def used_in_count(self): """ Return the number of part BOMs that this part appears in """ return self.used_in.count() def get_bom_hash(self): """ Return a checksum hash for the BOM for this part. Used to determine if the BOM has changed (and needs to be signed off!) The hash is calculated by hashing each line item in the BOM. returns a string representation of a hash object which can be compared with a stored value """ hash = hashlib.md5(str(self.id).encode()) for item in self.bom_items.all().prefetch_related('sub_part'): hash.update(str(item.get_item_hash()).encode()) return str(hash.digest()) @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 def validate_bom(self, user): """ Validate the BOM (mark the BOM as validated by the given User. - Calculates and stores the hash for the BOM - Saves the current date and the checking user """ # Validate each line item too for item in self.bom_items.all(): item.validate_hash() self.bom_checksum = self.get_bom_hash() self.bom_checked_by = user self.bom_checked_date = datetime.now().date() self.save() @transaction.atomic def clear_bom(self): """ Clear the BOM items for the part (delete all BOM lines). """ self.bom_items.all().delete() def required_parts(self): """ Return a list of parts required to make this part (list of BOM items) """ parts = [] for bom in self.bom_items.all().select_related('sub_part'): parts.append(bom.sub_part) return parts def get_allowed_bom_items(self): """ Return a list of parts which can be added to a BOM for this part. - Exclude parts which are not 'component' parts - Exclude parts which this part is in the BOM for """ parts = Part.objects.filter(component=True).exclude(id=self.id) parts = parts.exclude(id__in=[part.id for part in self.used_in.all()]) return parts @property def supplier_count(self): """ Return the number of supplier parts available for this part """ return self.supplier_parts.count() @property def has_pricing_info(self): """ Return true if there is pricing information for this part """ return self.get_price_range() is not None @property def has_complete_bom_pricing(self): """ Return true if there is pricing information for each item in the BOM. """ for item in self.bom_items.all().select_related('sub_part'): if not item.sub_part.has_pricing_info: return False return True 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) """ price_range = self.get_price_range(quantity, buy, bom) if price_range is None: return None min_price, max_price = price_range if min_price == max_price: return min_price min_price = normalize(min_price) max_price = normalize(max_price) return "{a} - {b}".format(a=min_price, b=max_price) def get_supplier_price_range(self, quantity=1): min_price = None max_price = None for supplier in self.supplier_parts.all(): price = supplier.get_price(quantity) if price is None: continue if min_price is None or price < min_price: min_price = price if max_price is None or price > max_price: max_price = price if min_price is None or max_price is None: return None min_price = normalize(min_price) max_price = normalize(max_price) return (min_price, max_price) def get_bom_price_range(self, quantity=1): """ Return the price range of the BOM for this part. Adds the minimum price for all components in the BOM. Note: If the BOM contains items without pricing information, these items cannot be included in the BOM! """ min_price = None max_price = None for item in self.bom_items.all().select_related('sub_part'): if item.sub_part.pk == self.pk: print("Warning: Item contains itself in BOM") continue prices = item.sub_part.get_price_range(quantity * item.quantity) if prices is None: continue low, high = prices if min_price is None: min_price = 0 if max_price is None: max_price = 0 min_price += low max_price += high if min_price is None or max_price is None: return None min_price = normalize(min_price) max_price = normalize(max_price) return (min_price, max_price) def get_price_range(self, quantity=1, buy=True, bom=True): """ Return the price range for this part. This price can be either: - Supplier price (if purchased from suppliers) - BOM price (if built from other parts) Returns: Minimum of the supplier price or BOM price. If no pricing available, returns None """ 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 if buy_price_range is None: return bom_price_range elif bom_price_range is None: return buy_price_range else: return (min(buy_price_range[0], bom_price_range[0]), max(buy_price_range[1], bom_price_range[1])) 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) """ # Copy the part image if kwargs.get('image', True): if other.image: # Reference the other image from this Part self.image = other.image # Copy the BOM data if kwargs.get('bom', False): for item in other.bom_items.all(): # Point the item to THIS part. # Set the pk to None so a new entry is created. item.part = self item.pk = None item.save() # Copy the fields that aren't available in the duplicate form self.salable = other.salable self.assembly = other.assembly self.component = other.component self.purchaseable = other.purchaseable self.trackable = other.trackable self.virtual = other.virtual self.save() @property def attachment_count(self): """ Count the number of attachments for this part. If the part is a variant of a template part, include the number of attachments for the template part. """ n = self.attachments.count() if self.variant_of: n += self.variant_of.attachments.count() return n def sales_orders(self): """ Return a list of sales orders which reference this part """ orders = [] for line in self.sales_order_line_items.all().prefetch_related( 'order'): if line.order not in orders: orders.append(line.order) return orders def purchase_orders(self): """ Return a list of purchase orders which reference this part """ orders = [] for part in self.supplier_parts.all().prefetch_related( 'purchase_order_line_items'): for order in part.purchase_orders(): if order not in orders: orders.append(order) return orders def open_purchase_orders(self): """ Return a list of open purchase orders against this part """ return [ order for order in self.purchase_orders() if order.status in PurchaseOrderStatus.OPEN ] def closed_purchase_orders(self): """ Return a list of closed purchase orders against this part """ return [ order for order in self.purchase_orders() if order.status not in PurchaseOrderStatus.OPEN ] @property def on_order(self): """ Return the total number of items on order for this part. """ orders = self.supplier_parts.filter( purchase_order_line_items__order__status__in=PurchaseOrderStatus. OPEN).aggregate( quantity=Sum('purchase_order_line_items__quantity'), received=Sum('purchase_order_line_items__received')) quantity = orders['quantity'] received = orders['received'] if quantity is None: quantity = 0 if received is None: received = 0 return quantity - received def get_parameters(self): """ Return all parameters for this part, ordered by name """ return self.parameters.order_by('template__name')
class Tag(models.Model): name = models.CharField(max_length=255) description = MarkdownxField() def __str__(self): return self.name
class Build(MPTTModel): """ A Build object organises the creation of new parts from the component parts. Attributes: part: The part to be built (from component BOM items) 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) completion_date: Date the build was completed link: External URL for extra information notes: Text notes """ def __str__(self): return "{q} x {part}".format(q=decimal2string(self.quantity), part=str(self.part.full_name)) def get_absolute_url(self): return reverse('build-detail', kwargs={'pk': self.id}) title = models.CharField( verbose_name=_('Build Title'), blank=False, max_length=100, help_text=_('Brief description of the build') ) parent = TreeForeignKey( 'self', on_delete=models.DO_NOTHING, blank=True, null=True, related_name='children', verbose_name=_('Parent Build'), help_text=_('Parent build to which this build is allocated'), ) part = models.ForeignKey( 'part.Part', verbose_name=_('Part'), on_delete=models.CASCADE, related_name='builds', limit_choices_to={ 'is_template': False, '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)') ) quantity = models.PositiveIntegerField( verbose_name=_('Build Quantity'), default=1, validators=[MinValueValidator(1)], help_text=_('Number of parts to build') ) 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) completion_date = models.DateField(null=True, blank=True) completed_by = models.ForeignKey( User, on_delete=models.SET_NULL, blank=True, null=True, related_name='builds_completed' ) link = 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') ) @property def output_count(self): return self.build_outputs.count() @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() def getAutoAllocations(self): """ Return a list of parts which will be allocated using the 'AutoAllocate' function. For each item in the BOM for the attached Part: - If there is a single StockItem, use that StockItem - Take as many parts as available (up to the quantity required for the BOM) - If there are multiple StockItems available, ignore (leave up to the user) Returns: A list object containing the StockItem objects to be allocated (and the quantities) """ allocations = [] for item in self.part.bom_items.all().prefetch_related('sub_part'): # How many parts required for this build? q_required = item.quantity * self.quantity # Grab a list of StockItem objects which are "in stock" stock = StockModels.StockItem.objects.filter(StockModels.StockItem.IN_STOCK_FILTER) # Filter by part reference stock = stock.filter(part=item.sub_part) # Ensure that the available stock items are in the correct location if self.take_from is not None: # Filter for stock that is located downstream of the designated location stock = stock.filter(location__in=[loc for loc in self.take_from.getUniqueChildren()]) # Only one StockItem to choose from? Default to that one! if len(stock) == 1: stock_item = stock[0] # Check that we have not already allocated this stock-item against this build build_items = BuildItem.objects.filter(build=self, stock_item=stock_item) if len(build_items) > 0: continue # Are there any parts available? if stock_item.quantity > 0: # Only take as many as are available if stock_item.quantity < q_required: q_required = stock_item.quantity allocation = { 'stock_item': stock_item, 'quantity': q_required, } allocations.append(allocation) return allocations @transaction.atomic def unallocateStock(self): """ Deletes all stock allocations for this build. """ BuildItem.objects.filter(build=self.id).delete() @transaction.atomic def autoAllocate(self): """ Run auto-allocation routine to allocate StockItems to this Build. Returns a list of dict objects with keys like: { 'stock_item': item, 'quantity': quantity, } See: getAutoAllocations() """ allocations = self.getAutoAllocations() for item in allocations: # Create a new allocation build_item = BuildItem( build=self, stock_item=item['stock_item'], quantity=item['quantity']) build_item.save() @transaction.atomic def completeBuild(self, location, serial_numbers, user): """ Mark the Build as COMPLETE - Takes allocated items from stock - Delete pending BuildItem objects """ # Complete the build allocation for each BuildItem for build_item in self.allocated_stock.all().prefetch_related('stock_item'): build_item.complete_allocation(user) # Check that the stock-item has been assigned to this build, and remove the builditem from the database if build_item.stock_item.build_order == self: build_item.delete() notes = 'Built {q} on {now}'.format( q=self.quantity, now=str(datetime.now().date()) ) # Generate the build outputs if self.part.trackable and serial_numbers: # Add new serial numbers for serial in serial_numbers: item = StockModels.StockItem.objects.create( part=self.part, build=self, location=location, quantity=1, serial=serial, batch=str(self.batch) if self.batch else '', notes=notes ) item.save() else: # Add stock of the newly created item item = StockModels.StockItem.objects.create( part=self.part, build=self, location=location, quantity=self.quantity, batch=str(self.batch) if self.batch else '', notes=notes ) item.save() # Finally, mark the build as complete self.completion_date = datetime.now().date() self.completed_by = user self.status = BuildStatus.COMPLETE self.save() return True def isFullyAllocated(self): """ Return True if this build has been fully allocated. """ bom_items = self.part.bom_items.all() for item in bom_items: part = item.sub_part if not self.isPartFullyAllocated(part): return False return True def isPartFullyAllocated(self, part): """ Check if a given Part is fully allocated for this Build """ return self.getAllocatedQuantity(part) >= self.getRequiredQuantity(part) def getRequiredQuantity(self, part): """ Calculate the quantity of <part> required to make this build. """ try: item = PartModels.BomItem.objects.get(part=self.part.id, sub_part=part.id) q = item.quantity except PartModels.BomItem.DoesNotExist: q = 0 return q * self.quantity def getAllocatedQuantity(self, part): """ Calculate the total number of <part> currently allocated to this build """ allocated = BuildItem.objects.filter(build=self.id, stock_item__part=part.id).aggregate(q=Coalesce(Sum('quantity'), 0)) return allocated['q'] def getUnallocatedQuantity(self, part): """ Calculate the quantity of <part> which still needs to be allocated to this build. Args: Part - the part to be tested Returns: The remaining allocated quantity """ return max(self.getRequiredQuantity(part) - self.getAllocatedQuantity(part), 0) @property def required_parts(self): """ Returns a dict of parts required to build this part (BOM) """ parts = [] for item in self.part.bom_items.all().prefetch_related('sub_part'): part = { 'part': item.sub_part, 'per_build': item.quantity, 'quantity': item.quantity * self.quantity, 'allocated': self.getAllocatedQuantity(item.sub_part) } parts.append(part) return parts @property def can_build(self): """ Return true if there are enough parts to supply build """ for item in self.required_parts: if item['part'].total_stock < item['quantity']: return False return True @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
class Company(models.Model): """ A Company object represents an external company. It may be a supplier or a customer (or both). Attributes: name: Brief name of the company description: Longer form description website: URL for the company website address: Postal address phone: contact phone number email: contact email address URL: Secondary URL e.g. for link to internal Wiki page image: Company image / logo notes: Extra notes about the company is_customer: boolean value, is this company a customer is_supplier: boolean value, is this company a supplier """ name = models.CharField(max_length=100, blank=False, unique=True, help_text=_('Company name')) description = models.CharField(max_length=500, help_text=_('Description of the company')) website = models.URLField(blank=True, help_text=_('Company website URL')) address = models.CharField(max_length=200, blank=True, help_text=_('Company address')) phone = models.CharField(max_length=50, blank=True, help_text=_('Contact phone number')) email = models.EmailField(blank=True, help_text=_('Contact email address')) contact = models.CharField(max_length=100, blank=True, help_text=_('Point of contact')) URL = InvenTreeURLField( blank=True, help_text=_('Link to external company information')) image = models.ImageField(upload_to=rename_company_image, max_length=255, null=True, blank=True) notes = MarkdownxField(blank=True) is_customer = models.BooleanField( default=False, help_text=_('Do you sell items to this company?')) is_supplier = models.BooleanField( default=True, help_text=_('Do you purchase items from this company?')) def __str__(self): """ Get string representation of a Company """ return "{n} - {d}".format(n=self.name, d=self.description) def get_absolute_url(self): """ Get the web URL for the detail view for this Company """ return reverse('company-detail', kwargs={'pk': self.id}) def get_image_url(self): """ Return the URL of the image for this company """ if self.image: return os.path.join(settings.MEDIA_URL, str(self.image.url)) else: return os.path.join(settings.STATIC_URL, 'img/blank_image.png') @property def part_count(self): """ The number of parts supplied by this company """ return self.parts.count() @property def has_parts(self): """ Return True if this company supplies any parts """ return self.part_count > 0 @property def stock_items(self): """ Return a list of all stock items supplied by this company """ stock = apps.get_model('stock', 'StockItem') return stock.objects.filter(supplier_part__supplier=self.id).all() @property def stock_count(self): """ Return the number of stock items supplied by this company """ stock = apps.get_model('stock', 'StockItem') return stock.objects.filter(supplier_part__supplier=self.id).count() def outstanding_purchase_orders(self): """ Return purchase orders which are 'outstanding' """ return self.purchase_orders.filter(status__in=OrderStatus.OPEN) def pending_purchase_orders(self): """ Return purchase orders which are PENDING (not yet issued) """ return self.purchase_orders.filter(status=OrderStatus.PENDING) def closed_purchase_orders(self): """ Return purchase orders which are not 'outstanding' - Complete - Failed / lost - Returned """ return self.purchase_orders.exclude(status__in=OrderStatus.OPEN) def complete_purchase_orders(self): return self.purchase_orders.filter(status=OrderStatus.COMPLETE) def failed_purchase_orders(self): """ Return any purchase orders which were not successful """ return self.purchase_orders.filter(status__in=OrderStatus.FAILED)
class MissionStatement(AbstractConference, SingleActiveModel): content = MarkdownxField(null=True, blank=True) def __str__(self): return self.content
class Entry(models.Model): name = models.CharField(max_length=64, blank=True) aka = models.CharField('a.k.a', max_length=256, blank=True) name_prefix = models.ForeignKey( 'self', verbose_name="Entry name prefix", help_text= "Use this parent/tag entry's name as prefix when displayed out of context.", null=True, blank=True, on_delete=models.SET_NULL, related_name="children_using_name") text = MarkdownxField(blank=True) file = models.FileField(upload_to='file', blank=True) image = models.ImageField(upload_to='image', blank=True) home = models.BooleanField(default=False, help_text="Display entry in home page.") date = models.DateTimeField(auto_now_add=True) updated_on = models.DateTimeField(auto_now=True) parents = models.ManyToManyField( 'self', related_name="children", symmetrical=False, blank=True, through='EntryParentThroughModel', through_fields=('child', 'parent'), ) tags = models.ManyToManyField( 'self', related_name="tagged_from", symmetrical=False, blank=True, ) order_with_respect_to = 'pk' def file_ext(self): if self.file: return self.file.name.split('.')[-1].lower() def file_is_video(self): return self.file_ext() in ( 'mp4', 'ogg', 'webm', ) def file_filename(self): return os.path.basename(self.file.name) def text_html(self): return mark_safe(markdownify(self.text)) def badge(self): t = Template( """<a class="badge badge-secondary entry" href="%s">%s</a>""" % (self.url_path(), self.span())) return t.render(TemplateContext({'entry': self})) def span(self, name_prefix=True): if self.image: img = """{% load responsive_images %}<img src="{% src entry.image 32x24 nocrop %}" /> """ else: img = '' t = Template("""<span>%s{{ name }}</span>""" % img) return t.render( TemplateContext({ 'entry': self, 'name': self.name_get(name_prefix=name_prefix) })) def span_no_name_prefix(self): return self.span(name_prefix=False) def url_path(self): return reverse('nav', args=('%s/' % self.pk, )) def name_get(self, name_prefix=True): name_strip = self.name.strip() text_strip = self.text.strip() if name_strip != '': name = name_strip elif text_strip != '': name = text_strip[0:16] if len(name) != len(text_strip): name = '%s…' % name else: name = '∅' if name_prefix and self.name_prefix: name = '%s: %s' % ( self.name_prefix.name_get_no_name_prefix(), name, ) return name def name_get_no_name_prefix(self): return self.name_get(name_prefix=False) def __str__(self): return self.name_get() class Meta: verbose_name_plural = "Entries"