From 774bfdb9e7b392c21bc181543544cc3b849430b4 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 12 May 2022 11:22:34 +1000 Subject: [PATCH 1/8] Adds APIDownloadMixin class to implement common behaviour --- InvenTree/InvenTree/api.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py index 171fe414d2..3b440a1f3d 100644 --- a/InvenTree/InvenTree/api.py +++ b/InvenTree/InvenTree/api.py @@ -61,6 +61,44 @@ class NotFoundView(AjaxView): return JsonResponse(data, status=404) +class APIDownloadMixin: + """ + Mixin for enabling a LIST endpoint to be downloaded a file. + + To download the data, add the ?export= to the query string. + + The implementing class must provided a download_queryset method, + e.g. + + def download_queryset(self, queryset, export_format): + 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) + """ + + def get(self, request, *args, **kwargs): + + export_format = request.query_params.get('export', None) + + if export_format: + queryset = self.filter_queryset(self.get_queryset()) + return self.download_queryset(queryset, export_format) + + else: + # Default to the parent class implementation + return super().get(request, *args, **kwargs) + + def download_queryset(self, queryset, export_format): + raise NotImplementedError("download_queryset method not implemented!") + + class AttachmentMixin: """ Mixin for creating attachment objects, From 650d082eca54f18235a6235d9b5a8facc62706de Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 12 May 2022 11:23:58 +1000 Subject: [PATCH 2/8] Bump API version --- InvenTree/InvenTree/api_version.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index e391a00bd1..d5699f8de8 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -4,11 +4,16 @@ InvenTree API version information # InvenTree API version -INVENTREE_API_VERSION = 47 +INVENTREE_API_VERSION = 48 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v48 -> 2022-05-12 : https://github.com/inventree/InvenTree/pull/2977 + - Adds "export to file" functionality for PurchaseOrder API endpoint + - Adds "export to file" functionality for SalesOrder API endpoint + - Adds "export to file" functionality for BuildOrder API endpoint + v47 -> 2022-05-10 : https://github.com/inventree/InvenTree/pull/2964 - Fixes barcode API error response when scanning a StockItem which does not exist - Fixes barcode API error response when scanning a StockLocation which does not exist From a77d4b97b46e8e1529324436f01fde8ab7d8dfac Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 12 May 2022 11:29:33 +1000 Subject: [PATCH 3/8] Refactor stock_list endpoint to use the new mixin --- InvenTree/stock/api.py | 39 ++++++++++++++++++--------------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index a42b6a2869..96a893e914 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -33,7 +33,7 @@ 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.api import AttachmentMixin, APIDownloadMixin from InvenTree.filters import InvenTreeOrderingFilter from order.models import PurchaseOrder @@ -505,7 +505,7 @@ class StockFilter(rest_filters.FilterSet): updated_after = rest_filters.DateFilter(label='Updated after', field_name='updated', lookup_expr='gte') -class StockList(generics.ListCreateAPIView): +class StockList(APIDownloadMixin, generics.ListCreateAPIView): """ API endpoint for list view of Stock objects - GET: Return a list of all StockItem objects (with optional query filters) @@ -646,6 +646,22 @@ class StockList(generics.ListCreateAPIView): return Response(response_data, status=status.HTTP_201_CREATED, headers=self.get_success_headers(serializer.data)) + def download_queryset(self, queryset, export_format): + """ + Download this queryset as a file. + Uses the APIDownloadMixin mixin class + """ + dataset = StockItemResource().export(queryset=queryset) + + filedata = dataset.export(export_format) + + filename = 'InvenTree_StockItems_{date}.{fmt}'.format( + date=datetime.now().strftime("%d-%b-%Y"), + fmt=export_format + ) + + return DownloadFile(filedata, filename) + def list(self, request, *args, **kwargs): """ Override the 'list' method, as the StockLocation objects @@ -658,25 +674,6 @@ class StockList(generics.ListCreateAPIView): 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: From 465e69c254f6cec5e3a7faeb24a0868ca8820331 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 12 May 2022 11:33:17 +1000 Subject: [PATCH 4/8] Refactor exporters for: - Part - PurchaseOrderLineItem --- InvenTree/order/api.py | 28 +++++++++++----------------- InvenTree/part/api.py | 29 +++++++++++------------------ 2 files changed, 22 insertions(+), 35 deletions(-) diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 2dab7684de..6e74262ac2 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -17,7 +17,7 @@ from company.models import SupplierPart from InvenTree.filters import InvenTreeOrderingFilter from InvenTree.helpers import str2bool, DownloadFile -from InvenTree.api import AttachmentMixin +from InvenTree.api import AttachmentMixin, APIDownloadMixin from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus from order.admin import PurchaseOrderLineItemResource @@ -407,7 +407,7 @@ class PurchaseOrderLineItemFilter(rest_filters.FilterSet): return queryset -class PurchaseOrderLineItemList(generics.ListCreateAPIView): +class PurchaseOrderLineItemList(APIDownloadMixin, generics.ListCreateAPIView): """ API endpoint for accessing a list of PurchaseOrderLineItem objects - GET: Return a list of PurchaseOrder Line Item objects @@ -460,25 +460,19 @@ class PurchaseOrderLineItemList(generics.ListCreateAPIView): return queryset + def download_queryset(self, queryset, export_format): + dataset = PurchaseOrderLineItemResource().export(queryset=queryset) + + filedata = dataset.export(export_format) + + filename = f"InvenTree_PurchaseOrderItems.{export_format}" + + return DownloadFile(filedata, filename) + def list(self, request, *args, **kwargs): queryset = self.filter_queryset(self.get_queryset()) - # Check if we wish to export the queried data to a file - 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 = PurchaseOrderLineItemResource().export(queryset=queryset) - - filedata = dataset.export(export_format) - - filename = f"InvenTree_PurchaseOrderData.{export_format}" - - return DownloadFile(filedata, filename) - page = self.paginate_queryset(queryset) if page is not None: diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index ca9accf9e2..0e0e918665 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -49,7 +49,7 @@ 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.api import AttachmentMixin, APIDownloadMixin from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus @@ -847,7 +847,7 @@ class PartFilter(rest_filters.FilterSet): virtual = rest_filters.BooleanFilter() -class PartList(generics.ListCreateAPIView): +class PartList(APIDownloadMixin, generics.ListCreateAPIView): """ API endpoint for accessing a list of Part objects @@ -897,6 +897,15 @@ class PartList(generics.ListCreateAPIView): return self.serializer_class(*args, **kwargs) + def download_queryset(self, queryset, export_format): + dataset = PartResource().export(queryset=queryset) + + filedata = dataset.export(export_format) + + filename = f"InvenTree_Parts.{export_format}" + + return DownloadFile(filedata, filename) + def list(self, request, *args, **kwargs): """ Overide the 'list' method, as the PartCategory objects are @@ -908,22 +917,6 @@ 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: From 1b1f7634b72af15fff917665878ffcfb5b1173ae Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 12 May 2022 11:41:25 +1000 Subject: [PATCH 5/8] Adds exporter and download button for PurchaseOrder table --- InvenTree/order/admin.py | 20 +++++++++++++++++++- InvenTree/order/api.py | 13 +++++++++++-- InvenTree/part/admin.py | 2 +- InvenTree/templates/js/translated/order.js | 4 +++- 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/InvenTree/order/admin.py b/InvenTree/order/admin.py index 0de28d5668..8ab8b832ef 100644 --- a/InvenTree/order/admin.py +++ b/InvenTree/order/admin.py @@ -5,8 +5,9 @@ from django.contrib import admin from import_export.admin import ImportExportModelAdmin -from import_export.resources import ModelResource from import_export.fields import Field +from import_export.resources import ModelResource +import import_export.widgets as widgets from .models import PurchaseOrder, PurchaseOrderLineItem, PurchaseOrderExtraLine from .models import SalesOrder, SalesOrderLineItem, SalesOrderExtraLine @@ -92,6 +93,23 @@ class SalesOrderAdmin(ImportExportModelAdmin): autocomplete_fields = ('customer',) +class PurchaseOrderResource(ModelResource): + """ + Class for managing import / export of PurchaseOrder data + """ + + # Add number of line items + line_items = Field(attribute='line_count', widget=widgets.IntegerWidget(), readonly=True) + + # Is this order overdue? + overdue = Field(attribute='is_overdue', widget=widgets.BooleanWidget(), readonly=True) + + class Meta: + model = PurchaseOrder + skip_unchanged = True + clean_model_instances = True + + class PurchaseOrderLineItemResource(ModelResource): """ Class for managing import / export of PurchaseOrderLineItem data """ diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 6e74262ac2..da037aa8b7 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -20,7 +20,7 @@ from InvenTree.helpers import str2bool, DownloadFile from InvenTree.api import AttachmentMixin, APIDownloadMixin from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus -from order.admin import PurchaseOrderLineItemResource +from order.admin import PurchaseOrderResource, PurchaseOrderLineItemResource import order.models as models import order.serializers as serializers from part.models import Part @@ -110,7 +110,7 @@ class PurchaseOrderFilter(rest_filters.FilterSet): ] -class PurchaseOrderList(generics.ListCreateAPIView): +class PurchaseOrderList(APIDownloadMixin, generics.ListCreateAPIView): """ API endpoint for accessing a list of PurchaseOrder objects - GET: Return list of PurchaseOrder objects (with filters) @@ -160,6 +160,15 @@ class PurchaseOrderList(generics.ListCreateAPIView): return queryset + def download_queryset(self, queryset, export_format): + dataset = PurchaseOrderResource().export(queryset=queryset) + + filedata = dataset.export(export_format) + + filename = f"InvenTree_PurchaseOrders.{export_format}" + + return DownloadFile(filedata, filename) + def filter_queryset(self, queryset): # Perform basic filtering diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py index fd0a16adc2..30dad8e995 100644 --- a/InvenTree/part/admin.py +++ b/InvenTree/part/admin.py @@ -4,8 +4,8 @@ from __future__ import unicode_literals from django.contrib import admin from import_export.admin import ImportExportModelAdmin -from import_export.resources import ModelResource from import_export.fields import Field +from import_export.resources import ModelResource import import_export.widgets as widgets from company.models import SupplierPart diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 538f37a710..dcd7307628 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -1394,7 +1394,9 @@ function loadPurchaseOrderTable(table, options) { filters[key] = options.params[key]; } - setupFilterList('purchaseorder', $(table)); + var target = '#filter-list-purchaseorder'; + + setupFilterList('purchaseorder', $(table), target, {download: true}); $(table).inventreeTable({ url: '{% url "api-po-list" %}', From d0ddb47b1f4c4a2229f4b2e69316fd45fc86a140 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 12 May 2022 11:44:05 +1000 Subject: [PATCH 6/8] Adds exporter and download button for sales orders --- InvenTree/order/admin.py | 17 +++++++++++++++++ InvenTree/order/api.py | 12 +++++++++++- InvenTree/templates/js/translated/order.js | 4 +++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/InvenTree/order/admin.py b/InvenTree/order/admin.py index 8ab8b832ef..a1e1b74256 100644 --- a/InvenTree/order/admin.py +++ b/InvenTree/order/admin.py @@ -135,6 +135,23 @@ class PurchaseOrderExtraLineResource(ModelResource): model = PurchaseOrderExtraLine +class SalesOrderResource(ModelResource): + """ + Class for managing import / export of SalesOrder data + """ + + # Add number of line items + line_items = Field(attribute='line_count', widget=widgets.IntegerWidget(), readonly=True) + + # Is this order overdue? + overdue = Field(attribute='is_overdue', widget=widgets.BooleanWidget(), readonly=True) + + class Meta: + model = SalesOrder + skip_unchanged = True + clean_model_instances = True + + class SalesOrderLineItemResource(ModelResource): """ Class for managing import / export of SalesOrderLineItem data diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index da037aa8b7..7c8a93125f 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -21,6 +21,7 @@ from InvenTree.api import AttachmentMixin, APIDownloadMixin from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus from order.admin import PurchaseOrderResource, PurchaseOrderLineItemResource +from order.admin import SalesOrderResource import order.models as models import order.serializers as serializers from part.models import Part @@ -583,7 +584,7 @@ class SalesOrderAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, Attachme serializer_class = serializers.SalesOrderAttachmentSerializer -class SalesOrderList(generics.ListCreateAPIView): +class SalesOrderList(APIDownloadMixin, generics.ListCreateAPIView): """ API endpoint for accessing a list of SalesOrder objects. @@ -633,6 +634,15 @@ class SalesOrderList(generics.ListCreateAPIView): return queryset + def download_queryset(self, queryset, export_format): + dataset = SalesOrderResource().export(queryset=queryset) + + filedata = dataset.export(export_format) + + filename = f"InvenTree_SalesOrders.{export_format}" + + return DownloadFile(filedata, filename) + def filter_queryset(self, queryset): """ Perform custom filtering operations on the SalesOrder queryset. diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index dcd7307628..372dc70a9a 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -2093,7 +2093,9 @@ function loadSalesOrderTable(table, options) { options.url = options.url || '{% url "api-so-list" %}'; - setupFilterList('salesorder', $(table)); + var target = '#filter-list-salesorder'; + + setupFilterList('salesorder', $(table), target, {download: true}); $(table).inventreeTable({ url: options.url, From c89547f58c98c6e1338d25df0ee8a04615508579 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 12 May 2022 12:44:15 +1000 Subject: [PATCH 7/8] Adds exporter and download functionality for BuildOrder table --- InvenTree/InvenTree/api.py | 2 +- InvenTree/build/admin.py | 49 +++++++++++++++++++++- InvenTree/build/api.py | 18 ++++++-- InvenTree/part/api.py | 1 - InvenTree/templates/js/translated/build.js | 2 +- 5 files changed, 63 insertions(+), 9 deletions(-) diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py index 3b440a1f3d..e468e8d1cf 100644 --- a/InvenTree/InvenTree/api.py +++ b/InvenTree/InvenTree/api.py @@ -87,7 +87,7 @@ class APIDownloadMixin: export_format = request.query_params.get('export', None) - if export_format: + if export_format and export_format in ['csv', 'tsv', 'xls', 'xlsx']: queryset = self.filter_queryset(self.get_queryset()) return self.download_queryset(queryset, export_format) diff --git a/InvenTree/build/admin.py b/InvenTree/build/admin.py index a612ad8460..55d7a1e2d2 100644 --- a/InvenTree/build/admin.py +++ b/InvenTree/build/admin.py @@ -2,9 +2,54 @@ from __future__ import unicode_literals from django.contrib import admin -from import_export.admin import ImportExportModelAdmin -from .models import Build, BuildItem +from import_export.admin import ImportExportModelAdmin +from import_export.fields import Field +from import_export.resources import ModelResource +import import_export.widgets as widgets + +from build.models import Build, BuildItem + +import part.models + + +class BuildResource(ModelResource): + """Class for managing import/export of Build data""" + # For some reason, we need to specify the fields individually for this ModelResource, + # but we don't for other ones. + # TODO: 2022-05-12 - Need to investigate why this is the case! + + pk = Field(attribute='pk') + + reference = Field(attribute='reference') + + title = Field(attribute='title') + + part = Field(attribute='part', widget=widgets.ForeignKeyWidget(part.models.Part)) + + part_name = Field(attribute='part__full_name', readonly=True) + + overdue = Field(attribute='is_overdue', readonly=True, widget=widgets.BooleanWidget()) + + completed = Field(attribute='completed', readonly=True) + + quantity = Field(attribute='quantity') + + status = Field(attribute='status') + + batch = Field(attribute='batch') + + notes = Field(attribute='notes') + + class Meta: + models = Build + skip_unchanged = True + report_skipped = False + clean_model_instances = True + exclude = [ + 'lft', 'rght', 'tree_id', 'level', + ] + class BuildAdmin(ImportExportModelAdmin): diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index e32b404ae2..4453232823 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -12,13 +12,15 @@ from rest_framework import filters, generics from django_filters.rest_framework import DjangoFilterBackend from django_filters import rest_framework as rest_filters -from InvenTree.api import AttachmentMixin -from InvenTree.helpers import str2bool, isNull +from InvenTree.api import AttachmentMixin, APIDownloadMixin +from InvenTree.helpers import str2bool, isNull, DownloadFile from InvenTree.filters import InvenTreeOrderingFilter from InvenTree.status_codes import BuildStatus -from .models import Build, BuildItem, BuildOrderAttachment +import build.admin import build.serializers +from build.models import Build, BuildItem, BuildOrderAttachment + from users.models import Owner @@ -71,7 +73,7 @@ class BuildFilter(rest_filters.FilterSet): return queryset -class BuildList(generics.ListCreateAPIView): +class BuildList(APIDownloadMixin, generics.ListCreateAPIView): """ API endpoint for accessing a list of Build objects. - GET: Return list of objects (with filters) @@ -123,6 +125,14 @@ class BuildList(generics.ListCreateAPIView): return queryset + def download_queryset(self, queryset, export_format): + dataset = build.admin.BuildResource().export(queryset=queryset) + + filedata = dataset.export(export_format) + filename = f"InvenTree_BuildOrders.{export_format}" + + return DownloadFile(filedata, filename) + def filter_queryset(self, queryset): queryset = super().filter_queryset(queryset) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 0e0e918665..622ca38669 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -901,7 +901,6 @@ class PartList(APIDownloadMixin, generics.ListCreateAPIView): dataset = PartResource().export(queryset=queryset) filedata = dataset.export(export_format) - filename = f"InvenTree_Parts.{export_format}" return DownloadFile(filedata, filename) diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index 94c2780a28..814f2a3247 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -2355,7 +2355,7 @@ function loadBuildTable(table, options) { var filterTarget = options.filterTarget || null; - setupFilterList('build', table, filterTarget); + setupFilterList('build', table, filterTarget, {download: true}); $(table).inventreeTable({ method: 'get', From 8edc0cc8938484bd3e01821ce8b1160568b91b36 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 12 May 2022 12:47:25 +1000 Subject: [PATCH 8/8] PEP fixes --- InvenTree/build/admin.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/InvenTree/build/admin.py b/InvenTree/build/admin.py index 55d7a1e2d2..43909d2197 100644 --- a/InvenTree/build/admin.py +++ b/InvenTree/build/admin.py @@ -16,7 +16,7 @@ import part.models class BuildResource(ModelResource): """Class for managing import/export of Build data""" # For some reason, we need to specify the fields individually for this ModelResource, - # but we don't for other ones. + # but we don't for other ones. # TODO: 2022-05-12 - Need to investigate why this is the case! pk = Field(attribute='pk') @@ -51,7 +51,6 @@ class BuildResource(ModelResource): ] - class BuildAdmin(ImportExportModelAdmin): exclude = [