diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index 9678819e41..2256f28210 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -24,6 +24,21 @@ class PartImageForm(HelperForm): ] +class BomValidateForm(HelperForm): + """ Simple confirmation form for BOM validation. + User is presented with a single checkbox input, + to confirm that the BOM for this part is valid + """ + + validate = forms.BooleanField(required=False, initial=False, help_text='Confirm that the BOM is correct') + + class Meta: + model = Part + fields = [ + 'validate' + ] + + class BomExportForm(HelperForm): # TODO - Define these choices somewhere else, and import them here diff --git a/InvenTree/part/migrations/0022_auto_20190512_1246.py b/InvenTree/part/migrations/0022_auto_20190512_1246.py new file mode 100644 index 0000000000..40f4d0dd4c --- /dev/null +++ b/InvenTree/part/migrations/0022_auto_20190512_1246.py @@ -0,0 +1,31 @@ +# Generated by Django 2.2 on 2019-05-12 02:46 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('part', '0021_auto_20190510_2220'), + ] + + operations = [ + migrations.AddField( + model_name='part', + name='bom_checked_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='boms_checked', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='part', + name='bom_checked_date', + field=models.DateField(blank=True, null=True), + ), + migrations.AddField( + model_name='part', + name='bom_checksum', + field=models.CharField(blank=True, help_text='Stored BOM checksum', max_length=128), + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 06ec2f1955..b781287930 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -16,7 +16,7 @@ from django.core.exceptions import ValidationError from django.urls import reverse from django.conf import settings -from django.db import models +from django.db import models, transaction from django.core.validators import MinValueValidator from django.contrib.staticfiles.templatetags.staticfiles import static @@ -24,7 +24,9 @@ from django.contrib.auth.models import User from django.db.models.signals import pre_delete from django.dispatch import receiver +from datetime import datetime from fuzzywuzzy import fuzz +import hashlib from InvenTree import helpers from InvenTree import validators @@ -300,23 +302,23 @@ class Part(models.Model): consumable = models.BooleanField(default=True, help_text='Can this part be used to build other parts?') - # Is this part "trackable"? - # Trackable parts can have unique instances - # which are assigned serial numbers (or batch numbers) - # and can have their movements tracked trackable = models.BooleanField(default=False, help_text='Does this part have tracking for unique items?') - # Is this part "purchaseable"? purchaseable = models.BooleanField(default=True, help_text='Can this part be purchased from external suppliers?') - # Can this part be sold to customers? salable = models.BooleanField(default=False, help_text="Can this part be sold to customers?") - # Is this part active? active = models.BooleanField(default=True, help_text='Is this part active?') notes = models.TextField(blank=True) + 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) + def format_barcode(self): """ Return a JSON string for formatting a barcode for this Part object """ @@ -467,13 +469,59 @@ class Part(models.Model): @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!) + + For hash is calculated from the following fields of each BOM item: + + - Part.full_name (if the part name changes, the BOM checksum is invalidated) + - quantity + - Note field + + returns a string representation of a hash object which can be compared with a stored value + """ + + hash = hashlib.md5('bom seed'.encode()) + + for item in self.bom_items.all(): + hash.update(str(item.sub_part.full_name).encode()) + hash.update(str(item.quantity).encode()) + hash.update(str(item.note).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 + """ + + self.bom_checksum = self.get_bom_hash() + self.bom_checked_by = user + self.bom_checked_date = datetime.now().date() + + self.save() + 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(): parts.append(bom.sub_part) @@ -481,7 +529,7 @@ class Part(models.Model): @property def supplier_count(self): - # Return the number of supplier parts available for this part + """ Return the number of supplier parts available for this part """ return self.supplier_parts.count() def export_bom(self, **kwargs): diff --git a/InvenTree/part/templates/part/bom.html b/InvenTree/part/templates/part/bom.html index c178ef455c..295fed23d6 100644 --- a/InvenTree/part/templates/part/bom.html +++ b/InvenTree/part/templates/part/bom.html @@ -11,6 +11,21 @@