diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index 6c89ff7265..3725d79c7b 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -52,6 +52,10 @@ font-size: 12px; } +.glyphicon-right { + float: right; +} + .starred-part { color: #ffbb00; } diff --git a/InvenTree/InvenTree/static/script/inventree/bom.js b/InvenTree/InvenTree/static/script/inventree/bom.js index 8d0accb83b..1b74088abf 100644 --- a/InvenTree/InvenTree/static/script/inventree/bom.js +++ b/InvenTree/InvenTree/static/script/inventree/bom.js @@ -133,7 +133,14 @@ function loadBomTable(table, options) { title: 'Part', sortable: true, 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 += ""; + } + + return html; } } ); diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py index 20ad569a12..6f9d2369e5 100644 --- a/InvenTree/part/admin.py +++ b/InvenTree/part/admin.py @@ -129,15 +129,17 @@ class PartStarAdmin(admin.ModelAdmin): class BomItemResource(ModelResource): """ Class for managing BomItem data import/export """ + level = Field(attribute='level', readonly=True) + 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) - stock = Field(attribute='sub_part__total_stock', readonly=True) + sub_assembly = Field(attribute='sub_part__assembly', readonly=True) class Meta: model = BomItem @@ -145,7 +147,7 @@ class BomItemResource(ModelResource): report_skipped = False clean_model_instances = True - exclude = ('checksum') + exclude = ['checksum', ] class BomItemAdmin(ImportExportModelAdmin): diff --git a/InvenTree/part/bom.py b/InvenTree/part/bom.py index 394314d461..a1f8b5ec6a 100644 --- a/InvenTree/part/bom.py +++ b/InvenTree/part/bom.py @@ -40,16 +40,43 @@ def MakeBomTemplate(fmt): 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. + + 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): 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) filename = '{n}_BOM.{fmt}'.format(n=part.full_name, fmt=fmt) diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index 7a4eecd583..5adf1e08dd 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -6,6 +6,7 @@ Django Forms for interacting with Part objects from __future__ import unicode_literals from InvenTree.forms import HelperForm +from InvenTree.helpers import GetExportFormats from mptt.fields import TreeNodeChoiceField 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): """ Simple confirmation form for BOM validation. User is presented with a single checkbox input, diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 3e1ed6949a..c23298a441 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -65,6 +65,8 @@ class PartBriefSerializer(InvenTreeModelSerializer): 'available_stock', 'image_url', 'active', + 'assembly', + 'virtual', ] diff --git a/InvenTree/part/templates/part/bom.html b/InvenTree/part/templates/part/bom.html index 3d61e24e2a..374acc94fd 100644 --- a/InvenTree/part/templates/part/bom.html +++ b/InvenTree/part/templates/part/bom.html @@ -44,16 +44,7 @@ {% if part.is_bom_valid == False %} {% endif %} -
- -
+ {% endif %} @@ -119,8 +110,14 @@ location.href = "{% url 'part-bom' part.id %}?edit=1"; }); - $(".download-bom").click(function () { - location.href = "{% url 'bom-export' part.id %}?format=" + $(this).attr('format'); + $("#download-bom").click(function () { + launchModalForm("{% url 'bom-export' part.id %}", + { + success: function(response) { + location.href = response.url; + }, + } + ); }); {% endif %} diff --git a/InvenTree/part/test_views.py b/InvenTree/part/test_views.py index 840f0a94ca..16711c78f6 100644 --- a/InvenTree/part/test_views.py +++ b/InvenTree/part/test_views.py @@ -81,7 +81,7 @@ class PartDetailTest(PartViewTestCase): def test_bom_download(self): """ 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.assertIn('streaming_content', dir(response)) diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 48e647302a..36be170bcb 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -33,7 +33,8 @@ part_parameter_urls = [ part_detail_urls = [ url(r'^edit/?', views.PartEdit.as_view(), name='part-edit'), 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'^duplicate/', views.PartDuplicate.as_view(), name='part-duplicate'), url(r'^make-variant/', views.MakePartVariant.as_view(), name='make-part-variant'), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 24717d55bb..584c8fe62f 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -1332,12 +1332,14 @@ class BomDownload(AjaxView): 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): export_format = 'csv' - return ExportBom(part, fmt=export_format) + return ExportBom(part, fmt=export_format, cascade=cascade) def get_data(self): 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): """ View to delete a Part object """