Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2019-09-05 20:13:17 +10:00
commit 755962c6a2
11 changed files with 204 additions and 23 deletions

View File

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

View File

@ -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 = "<button title='Validate BOM Item' class='bom-validate-button btn btn-default btn-glyph' type='button' pk='" + row.pk + "'><span class='glyphicon glyphicon-check'/></button>";
var bValid = "<span class='glyphicon glyphicon-ok'/>";
var bEdit = "<button title='Edit BOM Item' class='bom-edit-button btn btn-default btn-glyph' type='button' url='/part/bom/" + row.pk + "/edit'><span class='glyphicon glyphicon-edit'/></button>";
var bDelt = "<button title='Delete BOM Item' class='bom-delete-button btn btn-default btn-glyph' type='button' url='/part/bom/" + row.pk + "/delete'><span class='glyphicon glyphicon-trash'/></button>";
return "<div class='btn-group' role='group'>" + bEdit + bDelt + "</div>";
var html = "<div class='btn-group' role='group'>";
html += bEdit;
html += bDelt;
if (!row.validated) {
html += bValidate;
} else {
html += bValid;
}
html += "</div>";
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
}
);
});
}
}

View File

@ -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<pk>\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<pk>\d+)/?', BomDetail.as_view(), name='api-bom-detail'),
url(r'^(?P<pk>\d+)/', include(bom_item_urls)),
# Catch-all
url(r'^.*$', BomList.as_view(), name='api-bom-list'),

View File

@ -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),
),
]

View File

@ -631,12 +631,7 @@ 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:
- Part.full_name (if the part name changes, the BOM checksum is invalidated)
- Quantity
- Reference field
- Note field
The hash is calculated by hashing each line item in the BOM.
returns a string representation of a hash object which can be compared with a stored value
"""
@ -644,11 +639,7 @@ class Part(models.Model):
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.

View File

@ -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',
]

View File

@ -2,4 +2,9 @@
{% block pre_form_content %}
Confirm that the Bill of Materials (BOM) is valid for:<br><i>{{ part.full_name }}</i>
<div class='alert alert-warning alert-block'>
This will validate each line in the BOM.
</div>
{% endblock %}

View File

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

View File

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

View File

@ -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
@ -384,6 +396,11 @@ class StockAdjust(AjaxView, FormMixin):
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:
continue

View File

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