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; max-width: 250px;
} }
.bomrowvalid {
color: #050;
}
.bomrowinvalid {
color: #A00;
font-style: italic;
}
/* Part image icons with full-display on mouse hover */ /* Part image icons with full-display on mouse hover */
.hover-img-thumb { .hover-img-thumb {

View File

@ -113,14 +113,19 @@ function loadBomTable(table, options) {
]; ];
if (options.editable) { if (options.editable) {
/*
// TODO - Enable multi-select functionality
cols.push({ cols.push({
checkbox: true, checkbox: true,
title: 'Select', title: 'Select',
searchable: false, searchable: false,
sortable: false, sortable: false,
}); });
*/
} }
// Part column // Part column
cols.push( cols.push(
{ {
@ -230,10 +235,27 @@ function loadBomTable(table, options) {
if (options.editable) { if (options.editable) {
cols.push({ cols.push({
formatter: function(value, row, index, field) { 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 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>"; 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({ table.bootstrapTable({
sortable: true, sortable: true,
search: true, search: true,
rowStyle: function(row, index) {
if (row.validated) {
return {classes: 'bomrowvalid'};
} else {
return {classes: 'bomrowinvalid'};
}
},
formatNoMatches: function() { return "No BOM items found"; }, formatNoMatches: function() { return "No BOM items found"; },
clickToSelect: true, clickToSelect: true,
showFooter: 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 import status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import filters from rest_framework import filters, serializers
from rest_framework import generics, permissions from rest_framework import generics, permissions
from django.conf.urls import url, include from django.conf.urls import url, include
@ -303,7 +303,7 @@ class BomList(generics.ListCreateAPIView):
filter_fields = [ filter_fields = [
'part', '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 = [ cat_api_urls = [
url(r'^(?P<pk>\d+)/?', CategoryDetail.as_view(), name='api-part-category-detail'), 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'), 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_api_urls = [
# BOM Item Detail # 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 # Catch-all
url(r'^.*$', BomList.as_view(), name='api-bom-list'), 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. """ Return a checksum hash for the BOM for this part.
Used to determine if the BOM has changed (and needs to be signed off!) 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 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()) hash = hashlib.md5(str(self.id).encode())
for item in self.bom_items.all().prefetch_related('sub_part'): for item in self.bom_items.all().prefetch_related('sub_part'):
hash.update(str(item.sub_part.id).encode()) hash.update(str(item.get_item_hash()).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())
return str(hash.digest()) return str(hash.digest())
@ -667,6 +658,10 @@ class Part(models.Model):
- Saves the current date and the checking user - 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_checksum = self.get_bom_hash()
self.bom_checked_by = user self.bom_checked_by = user
self.bom_checked_date = datetime.now().date() self.bom_checked_date = datetime.now().date()
@ -1121,6 +1116,7 @@ class BomItem(models.Model):
reference: BOM reference field (e.g. part designators) 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%') 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 note: Note field for this BOM item
checksum: Validation checksum for the particular BOM line item
""" """
def get_absolute_url(self): def get_absolute_url(self):
@ -1154,6 +1150,56 @@ class BomItem(models.Model):
# Note attached to this BOM line item # Note attached to this BOM line item
note = models.CharField(max_length=500, blank=True, help_text='BOM item notes') 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): def clean(self):
""" Check validity of the BomItem model. """ 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) part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
sub_part_detail = PartBriefSerializer(source='sub_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) price_range = serializers.CharField(read_only=True)
validated = serializers.BooleanField(read_only=True, source='is_line_valid')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
# part_detail and sub_part_detail serializers are only included if requested. # part_detail and sub_part_detail serializers are only included if requested.
@ -171,4 +172,5 @@ class BomItemSerializer(InvenTreeModelSerializer):
'price_range', 'price_range',
'overage', 'overage',
'note', 'note',
'validated',
] ]

View File

@ -2,4 +2,9 @@
{% block pre_form_content %} {% block pre_form_content %}
Confirm that the Bill of Materials (BOM) is valid for:<br><i>{{ part.full_name }}</i> 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 %} {% endblock %}

View File

@ -120,7 +120,7 @@ class PartAPITest(APITestCase):
def test_get_bom_detail(self): def test_get_bom_detail(self):
# Get the detail for a single BomItem # 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') response = self.client.get(url, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['quantity'], 25) self.assertEqual(response.data['quantity'], 25)

View File

@ -117,10 +117,12 @@ class AdjustStockForm(forms.ModelForm):
return choices 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)') 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') # 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): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)

View File

@ -148,7 +148,15 @@ class StockAdjust(AjaxView, FormMixin):
stock_items = [] stock_items = []
def get_GET_items(self): 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 # Start with all 'in stock' items
items = StockItem.objects.filter(customer=None, belongs_to=None) items = StockItem.objects.filter(customer=None, belongs_to=None)
@ -224,6 +232,7 @@ class StockAdjust(AjaxView, FormMixin):
if not self.stock_action == 'move': if not self.stock_action == 'move':
form.fields.pop('destination') form.fields.pop('destination')
form.fields.pop('set_loc')
return form return form
@ -257,7 +266,7 @@ class StockAdjust(AjaxView, FormMixin):
self.request = request 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 # Update list of stock items
self.stock_items = self.get_POST_items() self.stock_items = self.get_POST_items()
@ -297,8 +306,9 @@ class StockAdjust(AjaxView, FormMixin):
} }
if valid: if valid:
result = self.do_action()
data['success'] = self.do_action() data['success'] = result
return self.renderJsonResponse(request, form, data=data) return self.renderJsonResponse(request, form, data=data)
@ -308,6 +318,8 @@ class StockAdjust(AjaxView, FormMixin):
if self.stock_action == 'move': if self.stock_action == 'move':
destination = None destination = None
set_default_loc = str2bool(self.request.POST.get('set_loc', False))
try: try:
destination = StockLocation.objects.get(id=self.request.POST.get('destination')) destination = StockLocation.objects.get(id=self.request.POST.get('destination'))
except StockLocation.DoesNotExist: except StockLocation.DoesNotExist:
@ -315,7 +327,7 @@ class StockAdjust(AjaxView, FormMixin):
except ValueError: except ValueError:
pass pass
return self.do_move(destination) return self.do_move(destination, set_default_loc)
elif self.stock_action == 'add': elif self.stock_action == 'add':
return self.do_add() return self.do_add()
@ -372,7 +384,7 @@ class StockAdjust(AjaxView, FormMixin):
return _("Counted stock for {n} items".format(n=count)) 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 """ """ Perform actual stock movement """
count = 0 count = 0
@ -384,6 +396,11 @@ class StockAdjust(AjaxView, FormMixin):
if item.new_quantity <= 0: if item.new_quantity <= 0:
continue 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) # Do not move to the same location (unless the quantity is different)
if destination == item.location and item.new_quantity == item.quantity: if destination == item.location and item.new_quantity == item.quantity:
continue continue

View File

@ -18,6 +18,7 @@ migrate:
python3 InvenTree/manage.py migrate python3 InvenTree/manage.py migrate
python3 InvenTree/manage.py migrate --run-syncdb python3 InvenTree/manage.py migrate --run-syncdb
python3 InvenTree/manage.py check python3 InvenTree/manage.py check
python3 InvenTree/manage.py collectstatic
# Install all required packages # Install all required packages
install: install: