mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'cascading-bom'
This commit is contained in:
commit
3479528d5b
@ -52,6 +52,10 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.glyphicon-right {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
.starred-part {
|
.starred-part {
|
||||||
color: #ffbb00;
|
color: #ffbb00;
|
||||||
}
|
}
|
||||||
|
@ -133,7 +133,14 @@ function loadBomTable(table, options) {
|
|||||||
title: 'Part',
|
title: 'Part',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
formatter: function(value, row, index, field) {
|
formatter: function(value, row, index, field) {
|
||||||
return imageHoverIcon(row.sub_part_detail.image_url) + renderLink(row.sub_part_detail.full_name, row.sub_part_detail.url);
|
var html = imageHoverIcon(row.sub_part_detail.image_url) + renderLink(row.sub_part_detail.full_name, row.sub_part_detail.url);
|
||||||
|
|
||||||
|
// Display an extra icon if this part is an assembly
|
||||||
|
if (row.sub_part_detail.assembly) {
|
||||||
|
html += "<a href='" + row.sub_part_detail.url + "bom'><span class='glyphicon-right glyphicon glyphicon-th-list'></span></a>";
|
||||||
|
}
|
||||||
|
|
||||||
|
return html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -129,15 +129,17 @@ class PartStarAdmin(admin.ModelAdmin):
|
|||||||
class BomItemResource(ModelResource):
|
class BomItemResource(ModelResource):
|
||||||
""" Class for managing BomItem data import/export """
|
""" Class for managing BomItem data import/export """
|
||||||
|
|
||||||
|
level = Field(attribute='level', readonly=True)
|
||||||
|
|
||||||
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
|
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
|
||||||
|
|
||||||
part_name = Field(attribute='part__full_name', readonly=True)
|
parent_part_name = Field(attribute='part__full_name', readonly=True)
|
||||||
|
|
||||||
sub_part = Field(attribute='sub_part', widget=widgets.ForeignKeyWidget(Part))
|
id = Field(attribute='sub_part', widget=widgets.ForeignKeyWidget(Part))
|
||||||
|
|
||||||
sub_part_name = Field(attribute='sub_part__full_name', readonly=True)
|
sub_part_name = Field(attribute='sub_part__full_name', readonly=True)
|
||||||
|
|
||||||
stock = Field(attribute='sub_part__total_stock', readonly=True)
|
sub_assembly = Field(attribute='sub_part__assembly', readonly=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = BomItem
|
model = BomItem
|
||||||
@ -145,7 +147,7 @@ class BomItemResource(ModelResource):
|
|||||||
report_skipped = False
|
report_skipped = False
|
||||||
clean_model_instances = True
|
clean_model_instances = True
|
||||||
|
|
||||||
exclude = ('checksum')
|
exclude = ['checksum', ]
|
||||||
|
|
||||||
|
|
||||||
class BomItemAdmin(ImportExportModelAdmin):
|
class BomItemAdmin(ImportExportModelAdmin):
|
||||||
|
@ -40,16 +40,43 @@ def MakeBomTemplate(fmt):
|
|||||||
return DownloadFile(data, filename)
|
return DownloadFile(data, filename)
|
||||||
|
|
||||||
|
|
||||||
def ExportBom(part, fmt='csv'):
|
def ExportBom(part, fmt='csv', cascade=False):
|
||||||
""" Export a BOM (Bill of Materials) for a given part.
|
""" Export a BOM (Bill of Materials) for a given part.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fmt: File format (default = 'csv')
|
||||||
|
cascade: If True, multi-level BOM output is supported. Otherwise, a flat top-level-only BOM is exported.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not IsValidBOMFormat(fmt):
|
if not IsValidBOMFormat(fmt):
|
||||||
fmt = 'csv'
|
fmt = 'csv'
|
||||||
|
|
||||||
bom_items = part.bom_items.all().order_by('id')
|
bom_items = []
|
||||||
|
|
||||||
dataset = BomItemResource().export(queryset=bom_items)
|
def add_items(items, level):
|
||||||
|
# Add items at a given layer
|
||||||
|
for item in items:
|
||||||
|
|
||||||
|
item.level = '-' * level
|
||||||
|
|
||||||
|
bom_items.append(item)
|
||||||
|
|
||||||
|
if item.sub_part.assembly:
|
||||||
|
add_items(item.sub_part.bom_items.all().order_by('id'), level + 1)
|
||||||
|
|
||||||
|
if cascade:
|
||||||
|
# Cascading (multi-level) BOM
|
||||||
|
|
||||||
|
# Start with the top level
|
||||||
|
items_to_process = part.bom_items.all().order_by('id')
|
||||||
|
|
||||||
|
add_items(items_to_process, 1)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# No cascading needed - just the top-level items
|
||||||
|
bom_items = [item for item in part.bom_items.all().order_by('id')]
|
||||||
|
|
||||||
|
dataset = BomItemResource().export(queryset=bom_items, cascade=cascade)
|
||||||
data = dataset.export(fmt)
|
data = dataset.export(fmt)
|
||||||
|
|
||||||
filename = '{n}_BOM.{fmt}'.format(n=part.full_name, fmt=fmt)
|
filename = '{n}_BOM.{fmt}'.format(n=part.full_name, fmt=fmt)
|
||||||
|
@ -6,6 +6,7 @@ Django Forms for interacting with Part objects
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from InvenTree.forms import HelperForm
|
from InvenTree.forms import HelperForm
|
||||||
|
from InvenTree.helpers import GetExportFormats
|
||||||
|
|
||||||
from mptt.fields import TreeNodeChoiceField
|
from mptt.fields import TreeNodeChoiceField
|
||||||
from django import forms
|
from django import forms
|
||||||
@ -28,6 +29,26 @@ class PartImageForm(HelperForm):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class BomExportForm(forms.Form):
|
||||||
|
""" Simple form to let user set BOM export options,
|
||||||
|
before exporting a BOM (bill of materials) file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
file_format = forms.ChoiceField(label=_("File Format"), help_text=_("Select output file format"))
|
||||||
|
|
||||||
|
cascading = forms.BooleanField(label=_("Cascading"), required=False, initial=False, help_text=_("Download cascading / multi-level BOM"))
|
||||||
|
|
||||||
|
def get_choices(self):
|
||||||
|
""" BOM export format choices """
|
||||||
|
|
||||||
|
return [(x, x.upper()) for x in GetExportFormats()]
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.fields['file_format'].choices = self.get_choices()
|
||||||
|
|
||||||
|
|
||||||
class BomValidateForm(HelperForm):
|
class BomValidateForm(HelperForm):
|
||||||
""" Simple confirmation form for BOM validation.
|
""" Simple confirmation form for BOM validation.
|
||||||
User is presented with a single checkbox input,
|
User is presented with a single checkbox input,
|
||||||
|
@ -65,6 +65,8 @@ class PartBriefSerializer(InvenTreeModelSerializer):
|
|||||||
'available_stock',
|
'available_stock',
|
||||||
'image_url',
|
'image_url',
|
||||||
'active',
|
'active',
|
||||||
|
'assembly',
|
||||||
|
'virtual',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -44,16 +44,7 @@
|
|||||||
{% if part.is_bom_valid == False %}
|
{% if part.is_bom_valid == False %}
|
||||||
<button class='btn btn-default' id='validate-bom' type='button'><span class='glyphicon glyphicon-check'></span></button>
|
<button class='btn btn-default' id='validate-bom' type='button'><span class='glyphicon glyphicon-check'></span></button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class='btn-group' role='group'>
|
<button title='Export BOM' class='btn btn-default' id='download-bom' type='button'><span class='glyphicon glyphicon-download-alt'></span></button>
|
||||||
<div class='dropdown'>
|
|
||||||
<button title='Export BOM' class='btn btn-default dropdown-toggle' data-toggle='dropdown' type='button'><span class='glyphicon glyphicon-download-alt'></span></button>
|
|
||||||
<ul class='dropdown-menu'>
|
|
||||||
<li><a href='#' class='download-bom' format='csv'>CSV</a></li>
|
|
||||||
<li><a href='#' class='download-bom' format='xlsx'>XLSX</a></li>
|
|
||||||
<li><a href='#' class='download-bom' format='json'>JSON</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -119,8 +110,14 @@
|
|||||||
location.href = "{% url 'part-bom' part.id %}?edit=1";
|
location.href = "{% url 'part-bom' part.id %}?edit=1";
|
||||||
});
|
});
|
||||||
|
|
||||||
$(".download-bom").click(function () {
|
$("#download-bom").click(function () {
|
||||||
location.href = "{% url 'bom-export' part.id %}?format=" + $(this).attr('format');
|
launchModalForm("{% url 'bom-export' part.id %}",
|
||||||
|
{
|
||||||
|
success: function(response) {
|
||||||
|
location.href = response.url;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -81,7 +81,7 @@ class PartDetailTest(PartViewTestCase):
|
|||||||
def test_bom_download(self):
|
def test_bom_download(self):
|
||||||
""" Test downloading a BOM for a valid part """
|
""" Test downloading a BOM for a valid part """
|
||||||
|
|
||||||
response = self.client.get(reverse('bom-export', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
response = self.client.get(reverse('bom-download', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertIn('streaming_content', dir(response))
|
self.assertIn('streaming_content', dir(response))
|
||||||
|
|
||||||
|
@ -33,7 +33,8 @@ part_parameter_urls = [
|
|||||||
part_detail_urls = [
|
part_detail_urls = [
|
||||||
url(r'^edit/?', views.PartEdit.as_view(), name='part-edit'),
|
url(r'^edit/?', views.PartEdit.as_view(), name='part-edit'),
|
||||||
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.BomDownload.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'^validate-bom/', views.BomValidate.as_view(), name='bom-validate'),
|
url(r'^validate-bom/', views.BomValidate.as_view(), name='bom-validate'),
|
||||||
url(r'^duplicate/', views.PartDuplicate.as_view(), name='part-duplicate'),
|
url(r'^duplicate/', views.PartDuplicate.as_view(), name='part-duplicate'),
|
||||||
url(r'^make-variant/', views.MakePartVariant.as_view(), name='make-part-variant'),
|
url(r'^make-variant/', views.MakePartVariant.as_view(), name='make-part-variant'),
|
||||||
|
@ -1332,12 +1332,14 @@ class BomDownload(AjaxView):
|
|||||||
|
|
||||||
part = get_object_or_404(Part, pk=self.kwargs['pk'])
|
part = get_object_or_404(Part, pk=self.kwargs['pk'])
|
||||||
|
|
||||||
export_format = request.GET.get('format', 'csv')
|
export_format = request.GET.get('file_format', 'csv')
|
||||||
|
|
||||||
|
cascade = str2bool(request.GET.get('cascade', False))
|
||||||
|
|
||||||
if not IsValidBOMFormat(export_format):
|
if not IsValidBOMFormat(export_format):
|
||||||
export_format = 'csv'
|
export_format = 'csv'
|
||||||
|
|
||||||
return ExportBom(part, fmt=export_format)
|
return ExportBom(part, fmt=export_format, cascade=cascade)
|
||||||
|
|
||||||
def get_data(self):
|
def get_data(self):
|
||||||
return {
|
return {
|
||||||
@ -1345,6 +1347,47 @@ class BomDownload(AjaxView):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BomExport(AjaxView):
|
||||||
|
""" Provide a simple form to allow the user to select BOM download options.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model = Part
|
||||||
|
form_class = part_forms.BomExportForm
|
||||||
|
ajax_form_title = _("Export Bill of Materials")
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
return self.renderJsonResponse(request, self.form_class())
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
|
||||||
|
# Extract POSTed form data
|
||||||
|
fmt = request.POST.get('file_format', 'csv').lower()
|
||||||
|
cascade = str2bool(request.POST.get('cascading', False))
|
||||||
|
|
||||||
|
try:
|
||||||
|
part = Part.objects.get(pk=self.kwargs['pk'])
|
||||||
|
except:
|
||||||
|
part = None
|
||||||
|
|
||||||
|
# Format a URL to redirect to
|
||||||
|
if part:
|
||||||
|
url = reverse('bom-download', kwargs={'pk': part.pk})
|
||||||
|
else:
|
||||||
|
url = ''
|
||||||
|
|
||||||
|
url += '?file_format=' + fmt
|
||||||
|
url += '&cascade=' + str(cascade)
|
||||||
|
|
||||||
|
print("URL:", url)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'form_valid': part is not None,
|
||||||
|
'url': url,
|
||||||
|
}
|
||||||
|
|
||||||
|
return self.renderJsonResponse(request, self.form_class(), data=data)
|
||||||
|
|
||||||
|
|
||||||
class PartDelete(AjaxDeleteView):
|
class PartDelete(AjaxDeleteView):
|
||||||
""" View to delete a Part object """
|
""" View to delete a Part object """
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user