From 4c3032e2f09499b0c34a61c622f40b1420246a4f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 12 May 2019 12:42:06 +1000 Subject: [PATCH 1/8] Add function to calculate BOM hash - Uses hashlib.md5 --- InvenTree/part/models.py | 24 +++++++++++++++++++++++ InvenTree/part/templates/part/detail.html | 4 ++++ 2 files changed, 28 insertions(+) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 06ec2f1955..3c7cc22a32 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -25,6 +25,7 @@ from django.db.models.signals import pre_delete from django.dispatch import receiver from fuzzywuzzy import fuzz +import hashlib from InvenTree import helpers from InvenTree import validators @@ -473,6 +474,29 @@ class Part(models.Model): def used_in_count(self): return self.used_in.count() + @property + 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.notes).encode()) + + return str(hash.digest()) + def required_parts(self): parts = [] for bom in self.bom_items.all(): diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index ddb0523a83..42c7af0bfe 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -51,6 +51,10 @@ {{ part.URL }} {% endif %} + + Hash + {{ part.get_bom_hash }} + Category From 2431ba2a04c50409f2cb7c9e0c8ae322add63ed2 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 12 May 2019 12:47:28 +1000 Subject: [PATCH 2/8] Add new fields to Part model - bom_checksum (stores checksum calculated when the BOM was checked) - bom_checked_by (User who checked the BOM) - bom_checked_date (When the BOM was last checked) --- .../migrations/0022_auto_20190512_1246.py | 31 +++++++++++++++++++ InvenTree/part/models.py | 16 +++++----- 2 files changed, 39 insertions(+), 8 deletions(-) create mode 100644 InvenTree/part/migrations/0022_auto_20190512_1246.py 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 3c7cc22a32..12bcca2f04 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -301,23 +301,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 """ @@ -493,7 +493,7 @@ class Part(models.Model): 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.notes).encode()) + hash.update(str(item.note).encode()) return str(hash.digest()) From 985986a844014f844639b5a640a667f073e0bc56 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 12 May 2019 12:53:56 +1000 Subject: [PATCH 3/8] New functions for Part model - is_bom_valid() - Tests if bom checksums match - check_bom() function to mark the BOM as valid --- InvenTree/part/models.py | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 12bcca2f04..5f844228d6 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 @@ -474,7 +474,6 @@ class Part(models.Model): def used_in_count(self): return self.used_in.count() - @property 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!) @@ -497,7 +496,29 @@ class Part(models.Model): 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 check_bom(self, user): + """ Check 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 = 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) @@ -505,7 +526,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): From 06deccca1c653b16c9e2c0dc5cff9bb010f9229e Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 12 May 2019 13:01:41 +1000 Subject: [PATCH 4/8] Rename check_bom to validate_bom --- InvenTree/part/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 5f844228d6..434c91be1a 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -504,7 +504,7 @@ class Part(models.Model): return self.get_bom_hash() == self.bom_checksum @transaction.atomic - def check_bom(self, user): + def validate_bom(self, user): """ Check the BOM (mark the BOM as validated by the given User. - Calculates and stores the hash for the BOM From 9149619f38264c5fbc00898ae8fdec41e67138da Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 12 May 2019 13:12:04 +1000 Subject: [PATCH 5/8] Make BOM tab badge red if the BOM is not validated --- InvenTree/part/models.py | 2 +- InvenTree/part/templates/part/tabs.html | 2 +- InvenTree/static/css/inventree.css | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 434c91be1a..22b0073e6c 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -505,7 +505,7 @@ class Part(models.Model): @transaction.atomic def validate_bom(self, user): - """ Check the BOM (mark the BOM as validated by the given 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 diff --git a/InvenTree/part/templates/part/tabs.html b/InvenTree/part/templates/part/tabs.html index d8e84a6f09..ff2cbbf4a0 100644 --- a/InvenTree/part/templates/part/tabs.html +++ b/InvenTree/part/templates/part/tabs.html @@ -12,7 +12,7 @@ {% endif %} {% if part.buildable %} - BOM{{ part.bom_count }} + BOM{{ part.bom_count }} Build{{ part.active_builds|length }} {% endif %} diff --git a/InvenTree/static/css/inventree.css b/InvenTree/static/css/inventree.css index 9ce6f52400..49e2d636c7 100644 --- a/InvenTree/static/css/inventree.css +++ b/InvenTree/static/css/inventree.css @@ -14,6 +14,7 @@ color: #ffcc00; } + /* CSS overrides for treeview */ .expand-icon { font-size: 11px; @@ -97,6 +98,10 @@ margin-left: 10px; } +.badge-alert { + background-color: #f33; +} + .part-thumb { width: 200px; height: 200px; From d17e36b9f946c777898b33efbb45d4b821f15e8b Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 12 May 2019 13:24:58 +1000 Subject: [PATCH 6/8] Add BOM checksum info panels to the BOM view --- InvenTree/part/templates/part/bom.html | 15 +++++++++++++++ InvenTree/static/css/inventree.css | 4 ++++ 2 files changed, 19 insertions(+) diff --git a/InvenTree/part/templates/part/bom.html b/InvenTree/part/templates/part/bom.html index c178ef455c..a94569d42c 100644 --- a/InvenTree/part/templates/part/bom.html +++ b/InvenTree/part/templates/part/bom.html @@ -11,6 +11,21 @@

Bill of Materials

+{% if part.bom_checked_date %} +{% if part.is_bom_valid %} +
+{% else %} +
+ The BOM for {{ part.full_name }} has changed, and must be validated.
+{% endif %} + The BOM for {{ part.full_name }} was last checked by {{ part.bom_checked_by }} on {{ part.bom_checked_date }} +
+{% else %} +
+ The BOM for {{ part.full_name }} has not been validated. +
+{% endif %} +
{% if editing_enabled %}
diff --git a/InvenTree/static/css/inventree.css b/InvenTree/static/css/inventree.css index 49e2d636c7..7761addc55 100644 --- a/InvenTree/static/css/inventree.css +++ b/InvenTree/static/css/inventree.css @@ -224,6 +224,10 @@ pointer-events: all; } +.alert-block { + display: block; +} + .btn { margin-left: 2px; margin-right: 2px; From c7f0d56be44b70d0a1a8f1364a5c5d80f2a224f3 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 12 May 2019 16:09:11 +1000 Subject: [PATCH 7/8] Don't display BOM hash any more! --- InvenTree/part/templates/part/detail.html | 4 ---- 1 file changed, 4 deletions(-) diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index 42c7af0bfe..ddb0523a83 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -51,10 +51,6 @@ {{ part.URL }} {% endif %} - - Hash - {{ part.get_bom_hash }} - Category From e3a9a70678a40cfb3ea7be73c30dd66355e8d524 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 12 May 2019 16:27:50 +1000 Subject: [PATCH 8/8] Add a form/view/etc for BOM validation --- InvenTree/part/forms.py | 15 ++++++ InvenTree/part/models.py | 7 ++- InvenTree/part/templates/part/bom.html | 12 +++++ .../part/templates/part/bom_validate.html | 5 ++ InvenTree/part/urls.py | 1 + InvenTree/part/views.py | 51 ++++++++++++++----- 6 files changed, 77 insertions(+), 14 deletions(-) create mode 100644 InvenTree/part/templates/part/bom_validate.html 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/models.py b/InvenTree/part/models.py index 22b0073e6c..b781287930 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -24,6 +24,7 @@ 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 @@ -468,14 +469,16 @@ 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. + """ 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: @@ -511,7 +514,7 @@ class Part(models.Model): - Saves the current date and the checking user """ - self.bom_checksum = get_bom_hash() + self.bom_checksum = self.get_bom_hash() self.bom_checked_by = user self.bom_checked_date = datetime.now().date() diff --git a/InvenTree/part/templates/part/bom.html b/InvenTree/part/templates/part/bom.html index a94569d42c..295fed23d6 100644 --- a/InvenTree/part/templates/part/bom.html +++ b/InvenTree/part/templates/part/bom.html @@ -39,6 +39,9 @@ @@ -87,6 +90,15 @@ {% else %} + $("#validate-bom").click(function() { + launchModalForm( + "{% url 'bom-validate' part.id %}", + { + reload: true, + } + ); + }); + $("#edit-bom").click(function () { location.href = "{% url 'part-bom' part.id %}?edit=True"; }); diff --git a/InvenTree/part/templates/part/bom_validate.html b/InvenTree/part/templates/part/bom_validate.html new file mode 100644 index 0000000000..f2c159349f --- /dev/null +++ b/InvenTree/part/templates/part/bom_validate.html @@ -0,0 +1,5 @@ +{% extends "modal_form.html" %} + +{% block pre_form_content %} +Confirm that the Bill of Materials (BOM) is valid for:
{{ part.full_name }} +{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 64b6517489..fb49af8a9b 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -35,6 +35,7 @@ part_detail_urls = [ url(r'^edit/?', views.PartEdit.as_view(), name='part-edit'), url(r'^delete/?', views.PartDelete.as_view(), name='part-delete'), url(r'^bom-export/?', views.BomDownload.as_view(), name='bom-export'), + url(r'^validate-bom/', views.BomValidate.as_view(), name='bom-validate'), url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'), url(r'^attachments/?', views.PartDetail.as_view(template_name='part/attachments.html'), name='part-attachments'), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 890cb8a519..135e3da144 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -331,12 +331,50 @@ class PartEdit(AjaxUpdateView): return form +class BomValidate(AjaxUpdateView): + """ Modal form view for validating a part BOM """ + + model = Part + ajax_form_title = "Validate BOM" + ajax_template_name = 'part/bom_validate.html' + context_object_name = 'part' + form_class = part_forms.BomValidateForm + + def get_context(self): + return { + 'part': self.get_object(), + } + + def get(self, request, *args, **kwargs): + + form = self.get_form() + + return self.renderJsonResponse(request, form, context=self.get_context()) + + def post(self, request, *args, **kwargs): + + form = self.get_form() + part = self.get_object() + + confirmed = str2bool(request.POST.get('validate', False)) + + if confirmed: + part.validate_bom(request.user) + else: + form.errors['validate'] = ['Confirm that the BOM is valid'] + + data = { + 'form_valid': confirmed + } + + return self.renderJsonResponse(request, form, data, context=self.get_context()) + + class BomExport(AjaxView): model = Part ajax_form_title = 'Export BOM' ajax_template_name = 'part/bom_export.html' - context_object_name = 'part' form_class = part_forms.BomExportForm def get_object(self): @@ -345,17 +383,6 @@ class BomExport(AjaxView): def get(self, request, *args, **kwargs): form = self.form_class() - """ - part = self.get_object() - - context = { - 'part': part - } - - if request.is_ajax(): - passs - """ - return self.renderJsonResponse(request, form) def post(self, request, *args, **kwargs):