diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index 3f64633062..d9515dbff9 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -81,6 +81,15 @@ max-width: 250px; } +.bomrowvalid { + color: #050; +} + +.bomrowinvalid { + color: #A00; + font-style: italic; +} + /* Part image icons with full-display on mouse hover */ .hover-img-thumb { diff --git a/InvenTree/InvenTree/static/script/inventree/bom.js b/InvenTree/InvenTree/static/script/inventree/bom.js index 450fdeb503..1e66af5c7e 100644 --- a/InvenTree/InvenTree/static/script/inventree/bom.js +++ b/InvenTree/InvenTree/static/script/inventree/bom.js @@ -113,14 +113,19 @@ function loadBomTable(table, options) { ]; if (options.editable) { + + /* + // TODO - Enable multi-select functionality cols.push({ checkbox: true, title: 'Select', searchable: false, sortable: false, }); + */ } + // Part column cols.push( { @@ -230,10 +235,27 @@ function loadBomTable(table, options) { if (options.editable) { cols.push({ formatter: function(value, row, index, field) { + + var bValidate = ""; + var bValid = ""; + var bEdit = ""; var bDelt = ""; - return "
" + bEdit + bDelt + "
"; + var html = "
"; + + html += bEdit; + html += bDelt; + + if (!row.validated) { + html += bValidate; + } else { + html += bValid; + } + + html += "
"; + + return html; } }); } @@ -256,6 +278,13 @@ function loadBomTable(table, options) { table.bootstrapTable({ sortable: true, search: true, + rowStyle: function(row, index) { + if (row.validated) { + return {classes: 'bomrowvalid'}; + } else { + return {classes: 'bomrowinvalid'}; + } + }, formatNoMatches: function() { return "No BOM items found"; }, clickToSelect: true, showFooter: true, @@ -288,5 +317,22 @@ function loadBomTable(table, options) { } }); }); + + table.on('click', '.bom-validate-button', function() { + var button = $(this); + + var url = '/api/bom/' + button.attr('pk') + '/validate/'; + + inventreePut( + url, + { + valid: true + }, + { + method: 'PATCH', + reloadOnSuccess: true + } + ); + }); } } \ No newline at end of file diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 1c6678f2d3..e9c6d32f0d 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -12,7 +12,7 @@ from django.db.models import Sum from rest_framework import status from rest_framework.response import Response -from rest_framework import filters +from rest_framework import filters, serializers from rest_framework import generics, permissions from django.conf.urls import url, include @@ -303,7 +303,7 @@ class BomList(generics.ListCreateAPIView): filter_fields = [ 'part', - 'sub_part' + 'sub_part', ] @@ -318,6 +318,35 @@ class BomDetail(generics.RetrieveUpdateDestroyAPIView): ] +class BomItemValidate(generics.UpdateAPIView): + """ API endpoint for validating a BomItem """ + + # Very simple serializers + class BomItemValidationSerializer(serializers.Serializer): + + valid = serializers.BooleanField(default=False) + + queryset = BomItem.objects.all() + serializer_class = BomItemValidationSerializer + + def update(self, request, *args, **kwargs): + """ Perform update request """ + + partial = kwargs.pop('partial', False) + + valid = request.data.get('valid', False) + + instance = self.get_object() + + serializer = self.get_serializer(instance, data=request.data, partial=partial) + serializer.is_valid(raise_exception=True) + + if type(instance) == BomItem: + instance.validate_hash(valid) + + return Response(serializer.data) + + cat_api_urls = [ url(r'^(?P\d+)/?', CategoryDetail.as_view(), name='api-part-category-detail'), @@ -345,10 +374,16 @@ part_api_urls = [ url(r'^.*$', PartList.as_view(), name='api-part-list'), ] +bom_item_urls = [ + + url(r'^validate/?', BomItemValidate.as_view(), name='api-bom-item-validate'), + + url(r'^.*$', BomDetail.as_view(), name='api-bom-item-detail'), +] bom_api_urls = [ # BOM Item Detail - url(r'^(?P\d+)/?', BomDetail.as_view(), name='api-bom-detail'), + url(r'^(?P\d+)/', include(bom_item_urls)), # Catch-all url(r'^.*$', BomList.as_view(), name='api-bom-list'), diff --git a/InvenTree/part/migrations/0017_bomitem_checksum.py b/InvenTree/part/migrations/0017_bomitem_checksum.py new file mode 100644 index 0000000000..3dfaae7a09 --- /dev/null +++ b/InvenTree/part/migrations/0017_bomitem_checksum.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.4 on 2019-09-05 02:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0016_auto_20190820_0257'), + ] + + operations = [ + migrations.AddField( + model_name='bomitem', + name='checksum', + field=models.CharField(blank=True, help_text='BOM line checksum', max_length=128), + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 4a4c5fb606..b13b6632c1 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -631,24 +631,15 @@ class Part(models.Model): """ 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: + The hash is calculated by hashing each line item in the BOM. - - Part.full_name (if the part name changes, the BOM checksum is invalidated) - - Quantity - - Reference field - - Note field - 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.sub_part.id).encode()) - hash.update(str(item.sub_part.full_name).encode()) - hash.update(str(item.quantity).encode()) - hash.update(str(item.note).encode()) - hash.update(str(item.reference).encode()) + hash.update(str(item.get_item_hash()).encode()) return str(hash.digest()) @@ -667,6 +658,10 @@ class Part(models.Model): - 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() @@ -1121,6 +1116,7 @@ class BomItem(models.Model): reference: BOM reference field (e.g. part designators) overage: Estimated losses for a Build. Can be expressed as absolute value (e.g. '7') or a percentage (e.g. '2%') note: Note field for this BOM item + checksum: Validation checksum for the particular BOM line item """ def get_absolute_url(self): @@ -1154,6 +1150,56 @@ class BomItem(models.Model): # Note attached to this BOM line item note = models.CharField(max_length=500, blank=True, help_text='BOM item notes') + checksum = models.CharField(max_length=128, blank=True, help_text='BOM line checksum') + + def get_item_hash(self): + """ Calculate the checksum hash of this BOM line item: + + The hash is calculated from the following fields: + + - Part.full_name (if the part name changes, the BOM checksum is invalidated) + - Quantity + - Reference field + - Note field + + """ + + # Seed the hash with the ID of this BOM item + hash = hashlib.md5(str(self.id).encode()) + + # Update the hash based on line information + hash.update(str(self.sub_part.id).encode()) + hash.update(str(self.sub_part.full_name).encode()) + hash.update(str(self.quantity).encode()) + hash.update(str(self.note).encode()) + hash.update(str(self.reference).encode()) + + return str(hash.digest()) + + def validate_hash(self, valid=True): + """ Mark this item as 'valid' (store the checksum hash). + + Args: + valid: If true, validate the hash, otherwise invalidate it (default = True) + """ + + if valid: + self.checksum = str(self.get_item_hash()) + else: + self.checksum = '' + + self.save() + + @property + def is_line_valid(self): + """ Check if this line item has been validated by the user """ + + # Ensure an empty checksum returns False + if len(self.checksum) == 0: + return False + + return self.get_item_hash() == self.checksum + def clean(self): """ Check validity of the BomItem model. diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 47b34b292f..a8d0df5954 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -131,6 +131,7 @@ class BomItemSerializer(InvenTreeModelSerializer): part_detail = PartBriefSerializer(source='part', many=False, read_only=True) sub_part_detail = PartBriefSerializer(source='sub_part', many=False, read_only=True) price_range = serializers.CharField(read_only=True) + validated = serializers.BooleanField(read_only=True, source='is_line_valid') def __init__(self, *args, **kwargs): # part_detail and sub_part_detail serializers are only included if requested. @@ -171,4 +172,5 @@ class BomItemSerializer(InvenTreeModelSerializer): 'price_range', 'overage', 'note', + 'validated', ] diff --git a/InvenTree/part/templates/part/bom_validate.html b/InvenTree/part/templates/part/bom_validate.html index f2c159349f..763d946bf2 100644 --- a/InvenTree/part/templates/part/bom_validate.html +++ b/InvenTree/part/templates/part/bom_validate.html @@ -2,4 +2,9 @@ {% block pre_form_content %} Confirm that the Bill of Materials (BOM) is valid for:
{{ part.full_name }} + +
+ This will validate each line in the BOM. +
+ {% endblock %} \ No newline at end of file diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index b0859e5c1e..1b1ef3bc07 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -120,7 +120,7 @@ class PartAPITest(APITestCase): def test_get_bom_detail(self): # Get the detail for a single BomItem - url = reverse('api-bom-detail', kwargs={'pk': 3}) + url = reverse('api-bom-item-detail', kwargs={'pk': 3}) response = self.client.get(url, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['quantity'], 25) diff --git a/Makefile b/Makefile index 0b3379a724..aaf795144e 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,7 @@ migrate: python3 InvenTree/manage.py migrate python3 InvenTree/manage.py migrate --run-syncdb python3 InvenTree/manage.py check + python3 InvenTree/manage.py collectstatic # Install all required packages install: