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 @@

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 %}
@@ -24,6 +39,9 @@ @@ -72,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/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/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): diff --git a/InvenTree/static/css/inventree.css b/InvenTree/static/css/inventree.css index 9ce6f52400..7761addc55 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; @@ -219,6 +224,10 @@ pointer-events: all; } +.alert-block { + display: block; +} + .btn { margin-left: 2px; margin-right: 2px;