diff --git a/InvenTree/company/templates/company/detail.html b/InvenTree/company/templates/company/detail.html index e63f217f4b..0717d02d4d 100644 --- a/InvenTree/company/templates/company/detail.html +++ b/InvenTree/company/templates/company/detail.html @@ -169,7 +169,9 @@
- {% include "filter_list.html" with id="customerstock" %} +
+ {% include "filter_list.html" with id="customerstock" %} +
@@ -282,12 +284,6 @@ filterKey: "companystock", }); - $("#stock-export").click(function() { - exportStock({ - supplier: {{ company.id }} - }); - }); - {% if company.is_manufacturer %} function reloadManufacturerPartTable() { diff --git a/InvenTree/company/templates/company/supplier_part.html b/InvenTree/company/templates/company/supplier_part.html index 89e8f91493..44e6756845 100644 --- a/InvenTree/company/templates/company/supplier_part.html +++ b/InvenTree/company/templates/company/supplier_part.html @@ -308,14 +308,6 @@ loadStockTable($("#stock-table"), { url: "{% url 'api-stock-list' %}", }); -$("#stock-export").click(function() { - - exportStock({ - supplier_part: {{ part.pk }}, - }); - -}); - $("#item-create").click(function() { createNewStockItem({ data: { diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 954060c456..4136014a84 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -26,6 +26,8 @@ from djmoney.contrib.exchange.exceptions import MissingRate from decimal import Decimal, InvalidOperation +from part.admin import PartResource + from .models import Part, PartCategory, PartRelated from .models import BomItem, BomItemSubstitute from .models import PartParameter, PartParameterTemplate @@ -43,6 +45,7 @@ from build.models import Build from . import serializers as part_serializers from InvenTree.helpers import str2bool, isNull, increment +from InvenTree.helpers import DownloadFile from InvenTree.api import AttachmentMixin from InvenTree.status_codes import BuildStatus @@ -726,6 +729,22 @@ class PartList(generics.ListCreateAPIView): queryset = self.filter_queryset(self.get_queryset()) + # Check if we wish to export the queried data to a file. + # If so, skip pagination! + export_format = request.query_params.get('export', None) + + if export_format: + export_format = str(export_format).strip().lower() + + if export_format in ['csv', 'tsv', 'xls', 'xlsx']: + dataset = PartResource().export(queryset=queryset) + + filedata = dataset.export(export_format) + + filename = f"InvenTree_Parts.{export_format}" + + return DownloadFile(filedata, filename) + page = self.paginate_queryset(queryset) if page is not None: diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html index e02c77509d..acbd0b16f1 100644 --- a/InvenTree/part/templates/part/category.html +++ b/InvenTree/part/templates/part/category.html @@ -153,9 +153,6 @@

{% trans "Parts" %}

{% include "spacer.html" %}
- {% if roles.part.add %}
- {% if part.is_template %} -
- {% blocktrans with full_name=part.full_name%}Showing stock for all variants of {{full_name}}{% endblocktrans %} -
- {% endif %} {% include "stock_table.html" %}
@@ -829,14 +824,7 @@ ], url: "{% url 'api-stock-list' %}", }); - - $("#stock-export").click(function() { - - exportStock({ - part: {{ part.pk }} - }); - }); - + $('#item-create').click(function () { createNewStockItem({ data: { diff --git a/InvenTree/part/test_views.py b/InvenTree/part/test_views.py index 5b6c460e1b..2171a09b17 100644 --- a/InvenTree/part/test_views.py +++ b/InvenTree/part/test_views.py @@ -58,14 +58,6 @@ class PartListTest(PartViewTestCase): self.assertIn('parts', keys) self.assertIn('user', keys) - def test_export(self): - """ Export part data to CSV """ - - response = self.client.get(reverse('part-export'), {'parts': '1,2,3,4,5,6,7,8,9,10'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - - self.assertEqual(response.status_code, 200) - self.assertIn('streaming_content', dir(response)) - class PartDetailTest(PartViewTestCase): diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index ba843f7d4b..55a3dc52eb 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -80,9 +80,6 @@ part_urls = [ # Download a BOM upload template url(r'^bom_template/?', views.BomUploadTemplate.as_view(), name='bom-upload-template'), - # Export data for multiple parts - url(r'^export/', views.PartExport.as_view(), name='part-export'), - # Individual part using pk url(r'^(?P\d+)/', include(part_detail_urls)), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index e0992364dd..4b1e0eca33 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -49,13 +49,11 @@ from . import settings as part_settings from .bom import MakeBomTemplate, ExportBom, IsValidBOMFormat from order.models import PurchaseOrderLineItem -from .admin import PartResource - from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView from InvenTree.views import QRCodeView from InvenTree.views import InvenTreeRoleMixin -from InvenTree.helpers import DownloadFile, str2bool +from InvenTree.helpers import str2bool class PartIndex(InvenTreeRoleMixin, ListView): @@ -709,69 +707,6 @@ class BomUpload(InvenTreeRoleMixin, DetailView): template_name = 'part/upload_bom.html' -class PartExport(AjaxView): - """ Export a CSV file containing information on multiple parts """ - - role_required = 'part.view' - - def get_parts(self, request): - """ Extract part list from the POST parameters. - Parts can be supplied as: - - - Part category - - List of part PK values - """ - - # Filter by part category - cat_id = request.GET.get('category', None) - - part_list = None - - if cat_id is not None: - try: - category = PartCategory.objects.get(pk=cat_id) - part_list = category.get_parts() - except (ValueError, PartCategory.DoesNotExist): - pass - - # Backup - All parts - if part_list is None: - part_list = Part.objects.all() - - # Also optionally filter by explicit list of part IDs - part_ids = request.GET.get('parts', '') - parts = [] - - for pk in part_ids.split(','): - try: - parts.append(int(pk)) - except ValueError: - pass - - if len(parts) > 0: - part_list = part_list.filter(pk__in=parts) - - # Prefetch related fields to reduce DB hits - part_list = part_list.prefetch_related( - 'category', - 'used_in', - 'builds', - 'supplier_parts__purchase_order_line_items', - 'stock_items__allocations', - ) - - return part_list - - def get(self, request, *args, **kwargs): - - parts = self.get_parts(request) - - dataset = PartResource().export(queryset=parts) - - csv = dataset.export('csv') - return DownloadFile(csv, 'InvenTree_Parts.csv') - - class BomUploadTemplate(AjaxView): """ Provide a BOM upload template file for download. diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index ed7a4b8c40..34563b38d7 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -30,6 +30,7 @@ from company.models import Company, SupplierPart from company.serializers import CompanySerializer, SupplierPartSerializer from InvenTree.helpers import str2bool, isNull, extract_serial_numbers +from InvenTree.helpers import DownloadFile from InvenTree.api import AttachmentMixin from InvenTree.filters import InvenTreeOrderingFilter @@ -40,6 +41,7 @@ from order.serializers import POSerializer from part.models import BomItem, Part, PartCategory from part.serializers import PartBriefSerializer +from stock.admin import StockItemResource from stock.models import StockLocation, StockItem from stock.models import StockItemTracking from stock.models import StockItemAttachment @@ -611,6 +613,27 @@ class StockList(generics.ListCreateAPIView): queryset = self.filter_queryset(self.get_queryset()) + params = request.query_params + + # Check if we wish to export the queried data to a file. + # If so, skip pagination! + export_format = params.get('export', None) + + if export_format: + export_format = str(export_format).strip().lower() + + if export_format in ['csv', 'tsv', 'xls', 'xlsx']: + dataset = StockItemResource().export(queryset=queryset) + + filedata = dataset.export(export_format) + + filename = 'InvenTree_Stocktake_{date}.{fmt}'.format( + date=datetime.now().strftime("%d-%b-%Y"), + fmt=export_format + ) + + return DownloadFile(filedata, filename) + page = self.paginate_queryset(queryset) if page is not None: @@ -641,7 +664,7 @@ class StockList(generics.ListCreateAPIView): supplier_part_ids.add(sp) # Do we wish to include Part detail? - if str2bool(request.query_params.get('part_detail', False)): + if str2bool(params.get('part_detail', False)): # Fetch only the required Part objects from the database parts = Part.objects.filter(pk__in=part_ids).prefetch_related( @@ -659,7 +682,7 @@ class StockList(generics.ListCreateAPIView): stock_item['part_detail'] = part_map.get(part_id, None) # Do we wish to include SupplierPart detail? - if str2bool(request.query_params.get('supplier_part_detail', False)): + if str2bool(params.get('supplier_part_detail', False)): supplier_parts = SupplierPart.objects.filter(pk__in=supplier_part_ids) @@ -673,7 +696,7 @@ class StockList(generics.ListCreateAPIView): stock_item['supplier_part_detail'] = supplier_part_map.get(part_id, None) # Do we wish to include StockLocation detail? - if str2bool(request.query_params.get('location_detail', False)): + if str2bool(params.get('location_detail', False)): # Fetch only the required StockLocation objects from the database locations = StockLocation.objects.filter(pk__in=location_ids).prefetch_related( diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 4c98db529b..1da0030bc6 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -239,15 +239,6 @@ }); {% endif %} - $("#stock-export").click(function() { - - exportStock({ - {% if location %} - location: {{ location.pk }} - {% endif %} - }); - }); - $('#location-create').click(function () { createStockLocation({ diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index a9dbe9e723..81973aed31 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -6,9 +6,12 @@ Unit testing for the Stock API from __future__ import unicode_literals import os +import io +import tablib from datetime import datetime, timedelta +import django.http from django.urls import reverse from rest_framework import status @@ -261,6 +264,56 @@ class StockItemListTest(StockAPITestCase): self.assertEqual(len(response['results']), n) + def export_data(self, filters=None): + + if not filters: + filters = {} + + filters['export'] = 'csv' + + response = self.client.get(self.list_url, data=filters) + + self.assertEqual(response.status_code, 200) + + self.assertTrue(isinstance(response, django.http.response.StreamingHttpResponse)) + + file_object = io.StringIO(response.getvalue().decode('utf-8')) + + dataset = tablib.Dataset().load(file_object, 'csv', headers=True) + + return dataset + + def test_export(self): + """ + Test exporting of Stock data via the API + """ + + dataset = self.export_data({}) + + self.assertEqual(len(dataset), 20) + + # Expected headers + headers = [ + 'part', + 'customer', + 'location', + 'parent', + 'quantity', + 'status', + ] + + for h in headers: + self.assertIn(h, dataset.headers) + + # Now, add a filter to the results + dataset = self.export_data({'location': 1}) + + self.assertEqual(len(dataset), 2) + + dataset = self.export_data({'part': 25}) + + self.assertEqual(len(dataset), 8) + class StockItemTest(StockAPITestCase): """ diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index b2536e0b97..bb4a56e2dc 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -47,8 +47,6 @@ stock_urls = [ url(r'^track/', include(stock_tracking_urls)), - url(r'^export/?', views.StockExport.as_view(), name='stock-export'), - # Individual stock items url(r'^item/(?P\d+)/', include(stock_item_detail_urls)), diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index d1fde25b0a..95cb498739 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -25,13 +25,13 @@ from InvenTree.views import QRCodeView from InvenTree.views import InvenTreeRoleMixin from InvenTree.forms import ConfirmForm -from InvenTree.helpers import str2bool, DownloadFile, GetExportFormats +from InvenTree.helpers import str2bool from InvenTree.helpers import extract_serial_numbers from decimal import Decimal, InvalidOperation from datetime import datetime, timedelta -from company.models import Company, SupplierPart +from company.models import SupplierPart from part.models import Part from .models import StockItem, StockLocation, StockItemTracking @@ -39,8 +39,6 @@ import common.settings from common.models import InvenTreeSetting from users.models import Owner -from .admin import StockItemResource - from . import forms as StockForms @@ -380,95 +378,6 @@ class StockItemDeleteTestData(AjaxUpdateView): return self.renderJsonResponse(request, form, data) -class StockExport(AjaxView): - """ Export stock data from a particular location. - Returns a file containing stock information for that location. - """ - - model = StockItem - role_required = 'stock.view' - - def get(self, request, *args, **kwargs): - - export_format = request.GET.get('format', 'csv').lower() - - # Check if a particular location was specified - loc_id = request.GET.get('location', None) - location = None - - if loc_id: - try: - location = StockLocation.objects.get(pk=loc_id) - except (ValueError, StockLocation.DoesNotExist): - pass - - # Check if a particular supplier was specified - sup_id = request.GET.get('supplier', None) - supplier = None - - if sup_id: - try: - supplier = Company.objects.get(pk=sup_id) - except (ValueError, Company.DoesNotExist): - pass - - # Check if a particular supplier_part was specified - sup_part_id = request.GET.get('supplier_part', None) - supplier_part = None - - if sup_part_id: - try: - supplier_part = SupplierPart.objects.get(pk=sup_part_id) - except (ValueError, SupplierPart.DoesNotExist): - pass - - # Check if a particular part was specified - part_id = request.GET.get('part', None) - part = None - - if part_id: - try: - part = Part.objects.get(pk=part_id) - except (ValueError, Part.DoesNotExist): - pass - - if export_format not in GetExportFormats(): - export_format = 'csv' - - filename = 'InvenTree_Stocktake_{date}.{fmt}'.format( - date=datetime.now().strftime("%d-%b-%Y"), - fmt=export_format - ) - - if location: - # Check if locations should be cascading - cascade = str2bool(request.GET.get('cascade', True)) - stock_items = location.get_stock_items(cascade) - else: - stock_items = StockItem.objects.all() - - if part: - stock_items = stock_items.filter(part=part) - - if supplier: - stock_items = stock_items.filter(supplier_part__supplier=supplier) - - if supplier_part: - stock_items = stock_items.filter(supplier_part=supplier_part) - - # Filter out stock items that are not 'in stock' - stock_items = stock_items.filter(StockItem.IN_STOCK_FILTER) - - # Pre-fetch related fields to reduce DB queries - stock_items = stock_items.prefetch_related('part', 'supplier_part__supplier', 'location', 'purchase_order', 'build') - - dataset = StockItemResource().export(queryset=stock_items) - - filedata = dataset.export(export_format) - - return DownloadFile(filedata, filename) - - class StockItemQRCode(QRCodeView): """ View for displaying a QR code for a StockItem object """ diff --git a/InvenTree/templates/js/translated/filters.js b/InvenTree/templates/js/translated/filters.js index 78ed30eefa..ceef79f66d 100644 --- a/InvenTree/templates/js/translated/filters.js +++ b/InvenTree/templates/js/translated/filters.js @@ -256,7 +256,7 @@ function generateFilterInput(tableKey, filterKey) { * @param {*} table - bootstrapTable element to update * @param {*} target - name of target element on page */ -function setupFilterList(tableKey, table, target) { +function setupFilterList(tableKey, table, target, options={}) { var addClicked = false; @@ -283,6 +283,11 @@ function setupFilterList(tableKey, table, target) { var buttons = ''; + // Add download button + if (options.download) { + buttons += ``; + } + buttons += ``; // If there are filters defined for this table, add more buttons @@ -295,7 +300,7 @@ function setupFilterList(tableKey, table, target) { } element.html(` -
+
${buttons}
`); @@ -322,6 +327,13 @@ function setupFilterList(tableKey, table, target) { $(table).bootstrapTable('refresh'); }); + // Add a callback for downloading table data + if (options.download) { + element.find(`#download-${tableKey}`).click(function() { + downloadTableData($(table)); + }); + } + // Add a callback for adding a new filter element.find(`#${add}`).click(function clicked() { @@ -358,14 +370,14 @@ function setupFilterList(tableKey, table, target) { reloadTableFilters(table, filters); // Run this function again - setupFilterList(tableKey, table, target); + setupFilterList(tableKey, table, target, options); } }); } else { addClicked = false; - setupFilterList(tableKey, table, target); + setupFilterList(tableKey, table, target, options); } }); @@ -376,7 +388,7 @@ function setupFilterList(tableKey, table, target) { reloadTableFilters(table, filters); - setupFilterList(tableKey, table, target); + setupFilterList(tableKey, table, target, options); }); // Add callback for deleting each filter @@ -390,7 +402,7 @@ function setupFilterList(tableKey, table, target) { reloadTableFilters(table, filters); // Run this function again! - setupFilterList(tableKey, table, target); + setupFilterList(tableKey, table, target, options); }); } diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index d42755a0f0..a0887f9473 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -1218,7 +1218,7 @@ function loadPartTable(table, url, options={}) { filters[key] = params[key]; } - setupFilterList('parts', $(table), options.filterTarget || null); + setupFilterList('parts', $(table), options.filterTarget, {download: true}); var columns = [ { diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 1ca89368dc..dcca969a28 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -43,7 +43,6 @@ duplicateStockItem, editStockItem, editStockLocation, - exportStock, findStockItemBySerialNumber, installStockItem, loadInstalledInTable, @@ -506,49 +505,6 @@ function stockStatusCodes() { } -/* - * Export stock table - */ -function exportStock(params={}) { - - constructFormBody({}, { - title: '{% trans "Export Stock" %}', - fields: { - format: { - label: '{% trans "Format" %}', - help_text: '{% trans "Select file format" %}', - required: true, - type: 'choice', - value: 'csv', - choices: exportFormatOptions(), - }, - sublocations: { - label: '{% trans "Include Sublocations" %}', - help_text: '{% trans "Include stock items in sublocations" %}', - type: 'boolean', - value: 'true', - } - }, - onSubmit: function(fields, form_options) { - - var format = getFormFieldValue('format', fields['format'], form_options); - var cascade = getFormFieldValue('sublocations', fields['sublocations'], form_options); - - // Hide the modal - $(form_options.modal).modal('hide'); - - var url = `{% url "stock-export" %}?format=${format}&cascade=${cascade}`; - - for (var key in params) { - url += `&${key}=${params[key]}`; - } - - location.href = url; - } - }); -} - - /** * Assign multiple stock items to a customer */ @@ -1615,7 +1571,7 @@ function loadStockTable(table, options) { original[k] = params[k]; } - setupFilterList(filterKey, table, filterTarget); + setupFilterList(filterKey, table, filterTarget, {download: true}); // Override the default values, or add new ones for (var key in params) { diff --git a/InvenTree/templates/js/translated/tables.js b/InvenTree/templates/js/translated/tables.js index c2418dbe78..8a6674299c 100644 --- a/InvenTree/templates/js/translated/tables.js +++ b/InvenTree/templates/js/translated/tables.js @@ -7,6 +7,7 @@ /* exported customGroupSorter, + downloadTableData, reloadtable, renderLink, reloadTableFilters, @@ -21,6 +22,62 @@ function reloadtable(table) { } +/** + * Download data from a table, via the API. + * This requires a number of conditions to be met: + * + * - The API endpoint supports data download (on the server side) + * - The table is "flat" (does not support multi-level loading, etc) + * - The table has been loaded using the inventreeTable() function, not bootstrapTable() + * (Refer to the "reloadTableFilters" function to see why!) + */ +function downloadTableData(table, opts={}) { + + // Extract table configuration options + var table_options = table.bootstrapTable('getOptions'); + + var url = table_options.url; + + if (!url) { + console.log('Error: downloadTableData could not find "url" parameter.'); + } + + var query_params = table_options.query_params || {}; + + url += '?'; + + constructFormBody({}, { + title: opts.title || '{% trans "Export Table Data" %}', + fields: { + format: { + label: '{% trans "Format" %}', + help_text: '{% trans "Select File Format" %}', + required: true, + type: 'choice', + value: 'csv', + choices: exportFormatOptions(), + } + }, + onSubmit: function(fields, form_options) { + var format = getFormFieldValue('format', fields['format'], form_options); + + // Hide the modal + $(form_options.modal).modal('hide'); + + for (const [key, value] of Object.entries(query_params)) { + url += `${key}=${value}&`; + } + + url += `export=${format}`; + + location.href = url; + } + }); +} + + + + /** * Render a URL for display * @param {String} text @@ -114,6 +171,10 @@ function reloadTableFilters(table, filters) { } } + // Store the total set of query params + // This is necessary for the "downloadTableData" function to work + options.query_params = params; + options.queryParams = function(tableParams) { return convertQueryParameters(tableParams, params); }; @@ -221,7 +282,11 @@ $.fn.inventreeTable = function(options) { // Extract query params var filters = options.queryParams || options.filters || {}; + // Store the total set of query params + options.query_params = filters; + options.queryParams = function(params) { + // Update the query parameters callback with the *new* filters return convertQueryParameters(params, filters); }; diff --git a/InvenTree/templates/stock_table.html b/InvenTree/templates/stock_table.html index a8a4ec6691..d609e78253 100644 --- a/InvenTree/templates/stock_table.html +++ b/InvenTree/templates/stock_table.html @@ -11,9 +11,6 @@
- {% if barcodes %}