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/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index 02c52defe1..6ad25ff279 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -117,10 +117,12 @@ class AdjustStockForm(forms.ModelForm): return choices - destination = forms.ChoiceField(label='Destination', required=True, help_text='Destination stock location') + destination = forms.ChoiceField(label='Destination', required=True, help_text=_('Destination stock location')) note = forms.CharField(label='Notes', required=True, help_text='Add note (required)') # transaction = forms.BooleanField(required=False, initial=False, label='Create Transaction', help_text='Create a stock transaction for these parts') - confirm = forms.BooleanField(required=False, initial=False, label='Confirm stock adjustment', help_text='Confirm movement of stock items') + confirm = forms.BooleanField(required=False, initial=False, label='Confirm stock adjustment', help_text=_('Confirm movement of stock items')) + + set_loc = forms.BooleanField(required=False, initial=False, label='Set Default Location', help_text=_('Set the destination as the default location for selected parts')) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 61d9517812..6ecdf1f3a2 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -148,7 +148,15 @@ class StockAdjust(AjaxView, FormMixin): stock_items = [] def get_GET_items(self): - """ Return list of stock items initally requested using GET """ + """ Return list of stock items initally requested using GET. + + Items can be retrieved by: + + a) List of stock ID - stock[]=1,2,3,4,5 + b) Parent part - part=3 + c) Parent location - location=78 + d) Single item - item=2 + """ # Start with all 'in stock' items items = StockItem.objects.filter(customer=None, belongs_to=None) @@ -224,6 +232,7 @@ class StockAdjust(AjaxView, FormMixin): if not self.stock_action == 'move': form.fields.pop('destination') + form.fields.pop('set_loc') return form @@ -257,7 +266,7 @@ class StockAdjust(AjaxView, FormMixin): self.request = request - self.stock_action = request.POST.get('stock_action').lower() + self.stock_action = request.POST.get('stock_action', 'invalid').lower() # Update list of stock items self.stock_items = self.get_POST_items() @@ -297,8 +306,9 @@ class StockAdjust(AjaxView, FormMixin): } if valid: + result = self.do_action() - data['success'] = self.do_action() + data['success'] = result return self.renderJsonResponse(request, form, data=data) @@ -308,6 +318,8 @@ class StockAdjust(AjaxView, FormMixin): if self.stock_action == 'move': destination = None + set_default_loc = str2bool(self.request.POST.get('set_loc', False)) + try: destination = StockLocation.objects.get(id=self.request.POST.get('destination')) except StockLocation.DoesNotExist: @@ -315,7 +327,7 @@ class StockAdjust(AjaxView, FormMixin): except ValueError: pass - return self.do_move(destination) + return self.do_move(destination, set_default_loc) elif self.stock_action == 'add': return self.do_add() @@ -372,7 +384,7 @@ class StockAdjust(AjaxView, FormMixin): return _("Counted stock for {n} items".format(n=count)) - def do_move(self, destination): + def do_move(self, destination, set_loc=None): """ Perform actual stock movement """ count = 0 @@ -383,6 +395,11 @@ class StockAdjust(AjaxView, FormMixin): # Avoid moving zero quantity if item.new_quantity <= 0: continue + + # If we wish to set the destination location to the default one + if set_loc: + item.part.default_location = destination + item.part.save() # Do not move to the same location (unless the quantity is different) if destination == item.location and item.new_quantity == item.quantity: 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: