2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-29 12:06:44 +00:00

Merge pull request #2477 from SchrodingersGat/duplicate-bom-form

Duplicate bom form
This commit is contained in:
Oliver 2021-12-21 23:06:44 +11:00 committed by GitHub
commit 774d834b7e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 271 additions and 216 deletions

View File

@ -454,6 +454,76 @@ class PartSerialNumberDetail(generics.RetrieveAPIView):
return Response(data) 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): class PartDetail(generics.RetrieveUpdateDestroyAPIView):
""" API endpoint for detail view of a single Part object """ """ API endpoint for detail view of a single Part object """
@ -1585,6 +1655,12 @@ part_api_urls = [
# Endpoint for extra serial number information # Endpoint for extra serial number information
url(r'^serial-numbers/', PartSerialNumberDetail.as_view(), name='api-part-serial-number-detail'), 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 # Part detail endpoint
url(r'^.*$', PartDetail.as_view(), name='api-part-detail'), url(r'^.*$', PartDetail.as_view(), name='api-part-detail'),
])), ])),

View File

@ -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): class BomMatchItemForm(MatchItemForm):
""" Override MatchItemForm fields """ """ Override MatchItemForm fields """

View File

@ -481,7 +481,7 @@ class Part(MPTTModel):
def __str__(self): def __str__(self):
return f"{self.full_name} - {self.description}" 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. Check if this Part can be added to the BOM of another part.
@ -491,13 +491,11 @@ class Part(MPTTModel):
b) The parent part is used in the BOM for *this* part 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 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: result = True
return
try:
if self.pk == parent.pk: if self.pk == parent.pk:
raise ValidationError({'sub_part': _("Part '{p1}' is used in BOM for '{p2}' (recursive)").format( raise ValidationError({'sub_part': _("Part '{p1}' is used in BOM for '{p2}' (recursive)").format(
p1=str(self), p1=str(self),
@ -517,7 +515,20 @@ class Part(MPTTModel):
)}) )})
# And recursively check too # And recursively check too
item.sub_part.checkAddToBOM(parent) 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): def checkIfSerialNumberExists(self, sn, exclude_self=False):
""" """
@ -1816,23 +1827,45 @@ class Part(MPTTModel):
clear - Remove existing BOM items first (default=True) clear - Remove existing BOM items first (default=True)
""" """
# Ignore if the other part is actually this part?
if other == self:
return
if clear: if clear:
# Remove existing BOM items # Remove existing BOM items
# Note: Inherited BOM items are *not* deleted! # Note: Inherited BOM items are *not* deleted!
self.bom_items.all().delete() 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 # Copy existing BOM items from another part
# Note: Inherited BOM Items will *not* be duplicated!! # 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, # If this part already has a BomItem pointing to the same sub-part,
# delete that BomItem from this part first! # delete that BomItem from this part first!
try: # Ignore invalid BomItem objects
existing = BomItem.objects.get(part=self, sub_part=bom_item.sub_part) if not bom_item.part or not bom_item.sub_part:
existing.delete() continue
except (BomItem.DoesNotExist):
pass
# 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.part = self
bom_item.pk = None bom_item.pk = None
@ -2677,7 +2710,7 @@ class BomItem(models.Model):
try: try:
# Check for circular BOM references # Check for circular BOM references
if self.sub_part: 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 the sub_part is 'trackable' then the 'quantity' field must be an integer
if self.sub_part.trackable: if self.sub_part.trackable:

View File

@ -9,6 +9,7 @@ from django.urls import reverse_lazy
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.utils.translation import ugettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from sql_util.utils import SubqueryCount, SubquerySum from sql_util.utils import SubqueryCount, SubquerySum
@ -636,3 +637,65 @@ class CategoryParameterTemplateSerializer(InvenTreeModelSerializer):
'parameter_template', 'parameter_template',
'default_value', '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),
)

View File

@ -1,17 +0,0 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
<p>
{% trans "Select parent part to copy BOM from" %}
</p>
{% if part.has_bom %}
<div class='alert alert-block alert-danger'>
<strong>{% trans "Warning" %}</strong><br>
{% trans "This part already has a Bill of Materials" %}<br>
</div>
{% endif %}
{% endblock %}

View File

@ -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:<br><em>{{ part }}</em>{% endblocktrans %}
<div class='alert alert-warning alert-block'>
{% trans 'This will validate each line in the BOM.' %}
</div>
{% endblock %}

View File

@ -585,14 +585,12 @@
}); });
$('#bom-duplicate').click(function() { $('#bom-duplicate').click(function() {
launchModalForm(
"{% url 'duplicate-bom' part.id %}", duplicateBom({{ part.pk }}, {
{ success: function(response) {
success: function() {
$('#bom-table').bootstrapTable('refresh'); $('#bom-table').bootstrapTable('refresh');
} }
} });
);
}); });
$("#bom-item-new").click(function () { $("#bom-item-new").click(function () {
@ -616,12 +614,10 @@
}); });
$("#validate-bom").click(function() { $("#validate-bom").click(function() {
launchModalForm(
"{% url 'bom-validate' part.id %}", validateBom({{ part.id }}, {
{ reload: true
reload: true, });
}
);
}); });
$("#download-bom").click(function () { $("#download-bom").click(function () {

View File

@ -35,12 +35,10 @@ part_detail_urls = [
url(r'^delete/?', views.PartDelete.as_view(), name='part-delete'), url(r'^delete/?', views.PartDelete.as_view(), name='part-delete'),
url(r'^bom-export/?', views.BomExport.as_view(), name='bom-export'), url(r'^bom-export/?', views.BomExport.as_view(), name='bom-export'),
url(r'^bom-download/?', views.BomDownload.as_view(), name='bom-download'), 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'^pricing/', views.PartPricing.as_view(), name='part-pricing'),
url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'), 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'), url(r'^qr_code/?', views.PartQRCode.as_view(), name='part-qr'),

View File

@ -694,100 +694,6 @@ class PartImageSelect(AjaxUpdateView):
return self.renderJsonResponse(request, form, data) 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): class BomUpload(InvenTreeRoleMixin, FileManagementFormView):
""" View for uploading a BOM file, and handling BOM data importing. """ View for uploading a BOM file, and handling BOM data importing.

View File

@ -207,6 +207,11 @@ function showApiError(xhr, url) {
title = '{% trans "Error 404: Resource Not Found" %}'; title = '{% trans "Error 404: Resource Not Found" %}';
message = '{% trans "The requested resource could not be located on the server" %}'; message = '{% trans "The requested resource could not be located on the server" %}';
break; break;
// Method not allowed
case 405:
title = '{% trans "Error 405: Method Not Allowed" %}';
message = '{% trans "HTTP method not allowed at URL" %}';
break;
// Timeout // Timeout
case 408: case 408:
title = '{% trans "Error 408: Timeout" %}'; title = '{% trans "Error 408: Timeout" %}';

View File

@ -661,7 +661,7 @@ function loadBomTable(table, options={}) {
if (!row.inherited) { if (!row.inherited) {
return yesNoLabel(false); return yesNoLabel(false);
} else if (row.part == options.parent_id) { } else if (row.part == options.parent_id) {
return '{% trans "Inherited" %}'; return yesNoLabel(true);
} else { } else {
// If this BOM item is inherited from a parent part // If this BOM item is inherited from a parent part
return renderLink( return renderLink(

View File

@ -21,6 +21,7 @@
*/ */
/* exported /* exported
duplicateBom,
duplicatePart, duplicatePart,
editCategory, editCategory,
editPart, editPart,
@ -39,6 +40,7 @@
loadStockPricingChart, loadStockPricingChart,
partStockLabel, partStockLabel,
toggleStar, toggleStar,
validateBom,
*/ */
/* Part API functions /* Part API functions
@ -428,6 +430,59 @@ function toggleStar(options) {
} }
/* Validate a BOM */
function validateBom(part_id, options={}) {
var html = `
<div class='alert alert-block alert-success'>
{% trans "Validating the BOM will mark each line item as valid" %}
</div>
`;
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={}) { function partStockLabel(part, options={}) {
if (part.in_stock) { if (part.in_stock) {