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 %} -
- {% trans "Warning" %}
- {% trans "This part already has a Bill of Materials" %}
-
-{% endif %} - -{% endblock %} diff --git a/InvenTree/part/templates/part/bom_validate.html b/InvenTree/part/templates/part/bom_validate.html deleted file mode 100644 index 3554f9c56a..0000000000 --- a/InvenTree/part/templates/part/bom_validate.html +++ /dev/null @@ -1,12 +0,0 @@ -{% extends "modal_form.html" %} - -{% load i18n %} - -{% block pre_form_content %} -{% blocktrans with part.full_name as part %}Confirm that the Bill of Materials (BOM) is valid for:
{{ part }}{% endblocktrans %} - -
- {% trans 'This will validate each line in the BOM.' %} -
- -{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index 999a9cba63..fb92293b3b 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -585,14 +585,12 @@ }); $('#bom-duplicate').click(function() { - launchModalForm( - "{% url 'duplicate-bom' part.id %}", - { - success: function() { - $('#bom-table').bootstrapTable('refresh'); - } + + duplicateBom({{ part.pk }}, { + success: function(response) { + $('#bom-table').bootstrapTable('refresh'); } - ); + }); }); $("#bom-item-new").click(function () { @@ -616,12 +614,10 @@ }); $("#validate-bom").click(function() { - launchModalForm( - "{% url 'bom-validate' part.id %}", - { - reload: true, - } - ); + + validateBom({{ part.id }}, { + reload: true + }); }); $("#download-bom").click(function () { diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index e5907e15e2..14f3e28b24 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -35,12 +35,10 @@ part_detail_urls = [ url(r'^delete/?', views.PartDelete.as_view(), name='part-delete'), url(r'^bom-export/?', views.BomExport.as_view(), name='bom-export'), url(r'^bom-download/?', views.BomDownload.as_view(), name='bom-download'), - url(r'^validate-bom/', views.BomValidate.as_view(), name='bom-validate'), url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'), url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'), - url(r'^bom-duplicate/?', views.BomDuplicate.as_view(), name='duplicate-bom'), url(r'^qr_code/?', views.PartQRCode.as_view(), name='part-qr'), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index af35cf9c1f..4304d2a95d 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -694,100 +694,6 @@ class PartImageSelect(AjaxUpdateView): return self.renderJsonResponse(request, form, data) -class BomDuplicate(AjaxUpdateView): - """ - View for duplicating BOM from a parent item. - """ - - model = Part - context_object_name = 'part' - ajax_form_title = _('Duplicate BOM') - ajax_template_name = 'part/bom_duplicate.html' - form_class = part_forms.BomDuplicateForm - - def get_form(self): - - form = super().get_form() - - # Limit choices to parents of the current part - parents = self.get_object().get_ancestors() - - form.fields['parent'].queryset = parents - - return form - - def get_initial(self): - initials = super().get_initial() - - parents = self.get_object().get_ancestors() - - if parents.count() == 1: - initials['parent'] = parents[0] - - return initials - - def validate(self, part, form): - - confirm = str2bool(form.cleaned_data.get('confirm', False)) - - if not confirm: - form.add_error('confirm', _('Confirm duplication of BOM from parent')) - - def save(self, part, form): - """ - Duplicate BOM from the specified parent - """ - - parent = form.cleaned_data.get('parent', None) - - clear = str2bool(form.cleaned_data.get('clear', True)) - - if parent: - part.copy_bom_from(parent, clear=clear) - - -class BomValidate(AjaxUpdateView): - """ - Modal form view for validating a part BOM - """ - - model = Part - ajax_form_title = _("Validate BOM") - ajax_template_name = 'part/bom_validate.html' - context_object_name = 'part' - form_class = part_forms.BomValidateForm - - def get_context(self): - return { - 'part': self.get_object(), - } - - def get(self, request, *args, **kwargs): - - form = self.get_form() - - return self.renderJsonResponse(request, form, context=self.get_context()) - - def validate(self, part, form, **kwargs): - - confirm = str2bool(form.cleaned_data.get('validate', False)) - - if not confirm: - form.add_error('validate', _('Confirm that the BOM is valid')) - - def save(self, part, form, **kwargs): - """ - Mark the BOM as validated - """ - - part.validate_bom(self.request.user) - - def get_data(self): - return { - 'success': _('Validated Bill of Materials') - } - - class BomUpload(InvenTreeRoleMixin, FileManagementFormView): """ View for uploading a BOM file, and handling BOM data importing. diff --git a/InvenTree/templates/js/translated/api.js b/InvenTree/templates/js/translated/api.js index d9c23f035f..eadf2e2afc 100644 --- a/InvenTree/templates/js/translated/api.js +++ b/InvenTree/templates/js/translated/api.js @@ -207,6 +207,11 @@ function showApiError(xhr, url) { title = '{% trans "Error 404: Resource Not Found" %}'; message = '{% trans "The requested resource could not be located on the server" %}'; break; + // Method not allowed + case 405: + title = '{% trans "Error 405: Method Not Allowed" %}'; + message = '{% trans "HTTP method not allowed at URL" %}'; + break; // Timeout case 408: title = '{% trans "Error 408: Timeout" %}'; diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index ffd8195e07..aa44931a88 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -661,7 +661,7 @@ function loadBomTable(table, options={}) { if (!row.inherited) { return yesNoLabel(false); } else if (row.part == options.parent_id) { - return '{% trans "Inherited" %}'; + return yesNoLabel(true); } else { // If this BOM item is inherited from a parent part return renderLink( diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index a357a965ed..da69266acd 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -21,6 +21,7 @@ */ /* exported + duplicateBom, duplicatePart, editCategory, editPart, @@ -39,6 +40,7 @@ loadStockPricingChart, partStockLabel, toggleStar, + validateBom, */ /* Part API functions @@ -428,6 +430,59 @@ function toggleStar(options) { } +/* Validate a BOM */ +function validateBom(part_id, options={}) { + + var html = ` +
+ {% trans "Validating the BOM will mark each line item as valid" %} +
+ `; + + constructForm(`/api/part/${part_id}/bom-validate/`, { + method: 'PUT', + fields: { + valid: {}, + }, + preFormContent: html, + title: '{% trans "Validate Bill of Materials" %}', + reload: options.reload, + onSuccess: function(response) { + showMessage('{% trans "Validated Bill of Materials" %}'); + } + }); +} + + +/* Duplicate a BOM */ +function duplicateBom(part_id, options={}) { + + constructForm(`/api/part/${part_id}/bom-copy/`, { + method: 'POST', + fields: { + part: { + icon: 'fa-shapes', + filters: { + assembly: true, + exclude_tree: part_id, + } + }, + include_inherited: {}, + remove_existing: {}, + skip_invalid: {}, + }, + confirm: true, + title: '{% trans "Copy Bill of Materials" %}', + onSuccess: function(response) { + if (options.success) { + options.success(response); + } + }, + }); + +} + + function partStockLabel(part, options={}) { if (part.in_stock) {