mirror of
https://github.com/inventree/InvenTree.git
synced 2025-05-02 21:38:48 +00:00
commit
8fe7284173
@ -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 {
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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'),
|
||||||
|
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,24 +631,15 @@ 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
|
||||||
"""
|
"""
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
@ -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',
|
||||||
]
|
]
|
||||||
|
@ -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 %}
|
@ -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)
|
||||||
|
1
Makefile
1
Makefile
@ -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:
|
||||||
|
Loading…
x
Reference in New Issue
Block a user