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: