mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-03 22:55:43 +00:00 
			
		
		
		
	Merge remote-tracking branch 'inventree/master'
This commit is contained in:
		@@ -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,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.
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
@@ -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:
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user