diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index a672e79051..0625a717a5 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -454,6 +454,76 @@ class PartSerialNumberDetail(generics.RetrieveAPIView): return Response(data) +class PartCopyBOM(generics.CreateAPIView): + """ + API endpoint for duplicating a BOM + """ + + queryset = Part.objects.all() + serializer_class = part_serializers.PartCopyBOMSerializer + + def get_serializer_context(self): + + ctx = super().get_serializer_context() + + try: + ctx['part'] = Part.objects.get(pk=self.kwargs.get('pk', None)) + except: + pass + + return ctx + + +class PartValidateBOM(generics.RetrieveUpdateAPIView): + """ + API endpoint for 'validating' the BOM for a given Part + """ + + class BOMValidateSerializer(serializers.ModelSerializer): + + class Meta: + model = Part + fields = [ + 'checksum', + 'valid', + ] + + checksum = serializers.CharField( + read_only=True, + source='bom_checksum', + ) + + valid = serializers.BooleanField( + write_only=True, + default=False, + label=_('Valid'), + help_text=_('Validate entire Bill of Materials'), + ) + + def validate_valid(self, valid): + if not valid: + raise ValidationError(_('This option must be selected')) + + queryset = Part.objects.all() + + serializer_class = BOMValidateSerializer + + def update(self, request, *args, **kwargs): + + part = self.get_object() + + partial = kwargs.pop('partial', False) + + serializer = self.get_serializer(part, data=request.data, partial=partial) + serializer.is_valid(raise_exception=True) + + part.validate_bom(request.user) + + return Response({ + 'checksum': part.bom_checksum, + }) + + class PartDetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a single Part object """ @@ -1585,6 +1655,12 @@ part_api_urls = [ # Endpoint for extra serial number information url(r'^serial-numbers/', PartSerialNumberDetail.as_view(), name='api-part-serial-number-detail'), + # Endpoint for duplicating a BOM for the specific Part + url(r'^bom-copy/', PartCopyBOM.as_view(), name='api-part-bom-copy'), + + # Endpoint for validating a BOM for the specific Part + url(r'^bom-validate/', PartValidateBOM.as_view(), name='api-part-bom-validate'), + # Part detail endpoint url(r'^.*$', PartDetail.as_view(), name='api-part-detail'), ])), diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index 2d9ae4dc30..c4c7d29228 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -55,54 +55,6 @@ class PartImageDownloadForm(HelperForm): ] -class BomDuplicateForm(HelperForm): - """ - Simple confirmation form for BOM duplication. - - Select which parent to select from. - """ - - parent = PartModelChoiceField( - label=_('Parent Part'), - help_text=_('Select parent part to copy BOM from'), - queryset=Part.objects.filter(is_template=True), - ) - - clear = forms.BooleanField( - required=False, initial=True, - help_text=_('Clear existing BOM items') - ) - - confirm = forms.BooleanField( - required=False, initial=False, - label=_('Confirm'), - help_text=_('Confirm BOM duplication') - ) - - class Meta: - model = Part - fields = [ - 'parent', - 'clear', - 'confirm', - ] - - -class BomValidateForm(HelperForm): - """ Simple confirmation form for BOM validation. - User is presented with a single checkbox input, - to confirm that the BOM for this part is valid - """ - - validate = forms.BooleanField(required=False, initial=False, label=_('validate'), help_text=_('Confirm that the BOM is correct')) - - class Meta: - model = Part - fields = [ - 'validate' - ] - - class BomMatchItemForm(MatchItemForm): """ Override MatchItemForm fields """ diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 5c6c07d9d8..b1aee26094 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -481,7 +481,7 @@ class Part(MPTTModel): def __str__(self): return f"{self.full_name} - {self.description}" - def checkAddToBOM(self, parent): + def check_add_to_bom(self, parent, raise_error=False, recursive=True): """ Check if this Part can be added to the BOM of another part. @@ -491,33 +491,44 @@ class Part(MPTTModel): b) The parent part is used in the BOM for *this* part c) The parent part is used in the BOM for any child parts under this one - Failing this check raises a ValidationError! - """ - if parent is None: - return + result = True - if self.pk == parent.pk: - raise ValidationError({'sub_part': _("Part '{p1}' is used in BOM for '{p2}' (recursive)").format( - p1=str(self), - p2=str(parent) - )}) - - bom_items = self.get_bom_items() - - # Ensure that the parent part does not appear under any child BOM item! - for item in bom_items.all(): - - # Check for simple match - if item.sub_part == parent: + try: + if self.pk == parent.pk: raise ValidationError({'sub_part': _("Part '{p1}' is used in BOM for '{p2}' (recursive)").format( - p1=str(parent), - p2=str(self) + p1=str(self), + p2=str(parent) )}) - # And recursively check too - item.sub_part.checkAddToBOM(parent) + bom_items = self.get_bom_items() + + # Ensure that the parent part does not appear under any child BOM item! + for item in bom_items.all(): + + # Check for simple match + if item.sub_part == parent: + raise ValidationError({'sub_part': _("Part '{p1}' is used in BOM for '{p2}' (recursive)").format( + p1=str(parent), + p2=str(self) + )}) + + # And recursively check too + if recursive: + result = result and item.sub_part.check_add_to_bom( + parent, + recursive=True, + raise_error=raise_error + ) + + except ValidationError as e: + if raise_error: + raise e + else: + return False + + return result def checkIfSerialNumberExists(self, sn, exclude_self=False): """ @@ -1816,23 +1827,45 @@ class Part(MPTTModel): clear - Remove existing BOM items first (default=True) """ + # Ignore if the other part is actually this part? + if other == self: + return + if clear: # Remove existing BOM items # Note: Inherited BOM items are *not* deleted! self.bom_items.all().delete() + # List of "ancestor" parts above this one + my_ancestors = self.get_ancestors(include_self=False) + + raise_error = not kwargs.get('skip_invalid', True) + + include_inherited = kwargs.get('include_inherited', False) + # Copy existing BOM items from another part # Note: Inherited BOM Items will *not* be duplicated!! - for bom_item in other.get_bom_items(include_inherited=False).all(): + for bom_item in other.get_bom_items(include_inherited=include_inherited).all(): # If this part already has a BomItem pointing to the same sub-part, # delete that BomItem from this part first! - try: - existing = BomItem.objects.get(part=self, sub_part=bom_item.sub_part) - existing.delete() - except (BomItem.DoesNotExist): - pass + # Ignore invalid BomItem objects + if not bom_item.part or not bom_item.sub_part: + continue + # Ignore ancestor parts which are inherited + if bom_item.part in my_ancestors and bom_item.inherited: + continue + + # Skip if already exists + if BomItem.objects.filter(part=self, sub_part=bom_item.sub_part).exists(): + continue + + # Skip (or throw error) if BomItem is not valid + if not bom_item.sub_part.check_add_to_bom(self, raise_error=raise_error): + continue + + # Construct a new BOM item bom_item.part = self bom_item.pk = None @@ -2677,7 +2710,7 @@ class BomItem(models.Model): try: # Check for circular BOM references if self.sub_part: - self.sub_part.checkAddToBOM(self.part) + self.sub_part.check_add_to_bom(self.part, raise_error=True) # If the sub_part is 'trackable' then the 'quantity' field must be an integer if self.sub_part.trackable: diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 820962b613..16ecc8da21 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -9,6 +9,7 @@ from django.urls import reverse_lazy from django.db import models from django.db.models import Q from django.db.models.functions import Coalesce +from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers from sql_util.utils import SubqueryCount, SubquerySum @@ -636,3 +637,65 @@ class CategoryParameterTemplateSerializer(InvenTreeModelSerializer): 'parameter_template', 'default_value', ] + + +class PartCopyBOMSerializer(serializers.Serializer): + """ + Serializer for copying a BOM from another part + """ + + class Meta: + fields = [ + 'part', + 'remove_existing', + ] + + part = serializers.PrimaryKeyRelatedField( + queryset=Part.objects.all(), + many=False, + required=True, + allow_null=False, + label=_('Part'), + help_text=_('Select part to copy BOM from'), + ) + + def validate_part(self, part): + """ + Check that a 'valid' part was selected + """ + + return part + + remove_existing = serializers.BooleanField( + label=_('Remove Existing Data'), + help_text=_('Remove existing BOM items before copying'), + default=True, + ) + + include_inherited = serializers.BooleanField( + label=_('Include Inherited'), + help_text=_('Include BOM items which are inherited from templated parts'), + default=False, + ) + + skip_invalid = serializers.BooleanField( + label=_('Skip Invalid Rows'), + help_text=_('Enable this option to skip invalid rows'), + default=False, + ) + + def save(self): + """ + Actually duplicate the BOM + """ + + base_part = self.context['part'] + + data = self.validated_data + + base_part.copy_bom_from( + data['part'], + clear=data.get('remove_existing', True), + skip_invalid=data.get('skip_invalid', False), + include_inherited=data.get('include_inherited', False), + ) diff --git a/InvenTree/part/templates/part/bom_duplicate.html b/InvenTree/part/templates/part/bom_duplicate.html deleted file mode 100644 index 1d8ccc7d1a..0000000000 --- a/InvenTree/part/templates/part/bom_duplicate.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends "modal_form.html" %} -{% load i18n %} - -{% block pre_form_content %} - -
- {% trans "Select parent part to copy BOM from" %} -
- -{% if part.has_bom %} -