mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
755962c6a2
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
@ -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'),
|
||||
|
18
InvenTree/part/migrations/0017_bomitem_checksum.py
Normal file
18
InvenTree/part/migrations/0017_bomitem_checksum.py
Normal 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),
|
||||
),
|
||||
]
|
@ -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.
|
||||
|
||||
|
@ -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',
|
||||
]
|
||||
|
@ -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 %}
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user