From 151f2cae6f9dd48a54a8d1e0f61758b62fab9307 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 12 May 2022 10:45:30 +1000 Subject: [PATCH 01/16] Do not redirect requests for media / static / api / js files - For these paths, just return a 401 - This is necessary to stop unauthorized calls to the API or to request media files from redirecting to the login page --- InvenTree/InvenTree/middleware.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/InvenTree/InvenTree/middleware.py b/InvenTree/InvenTree/middleware.py index 91cfefc6d6..b6550379e2 100644 --- a/InvenTree/InvenTree/middleware.py +++ b/InvenTree/InvenTree/middleware.py @@ -1,9 +1,12 @@ -from django.shortcuts import HttpResponseRedirect -from django.urls import reverse_lazy, Resolver404 -from django.shortcuts import redirect -from django.urls import include, re_path +# -*- coding: utf-8 -*- + from django.conf import settings from django.contrib.auth.middleware import PersistentRemoteUserMiddleware +from django.http import HttpResponse +from django.shortcuts import HttpResponseRedirect +from django.shortcuts import redirect +from django.urls import reverse_lazy, Resolver404 +from django.urls import include, re_path import logging @@ -82,11 +85,23 @@ class AuthRequiredMiddleware(object): reverse_lazy('admin:logout'), ] - if path not in urls and not path.startswith('/api/'): + # Do not redirect requests to any of these paths + paths_ignore = [ + '/api/', + '/js/', + '/media/', + '/static/', + ] + + if path not in urls and not any([path.startswith(p) for p in paths_ignore]): # Save the 'next' parameter to pass through to the login view return redirect('{}?next={}'.format(reverse_lazy('account_login'), request.path)) + else: + # Return a 401 (Unauthorized) response code for this request + return HttpResponse('Unauthorized', status=401) + response = self.get_response(request) return response From aa9ee15fb46499206033c4e8acb89cbed6f6e47d Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 12 May 2022 10:52:53 +1000 Subject: [PATCH 02/16] Fix CI pipeline for python checks - Recently updated the python binding test framework --- .github/workflows/qc_checks.yaml | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index 0aabd064bc..3de8c3d808 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -124,6 +124,16 @@ jobs: env: wrapper_name: inventree-python + INVENTREE_DB_ENGINE: django.db.backends.sqlite3 + INVENTREE_DB_NAME: ../inventree_unit_test_db.sqlite3 + INVENTREE_MEDIA_ROOT: ../test_inventree_media + INVENTREE_STATIC_ROOT: ../test_inventree_static + INVENTREE_ADMIN_USER: testuser + INVENTREE_ADMIN_PASSWORD: testpassword + INVENTREE_ADMIN_EMAIL: test@test.com + INVENTREE_PYTHON_TEST_SERVER: http://localhost:12345 + INVENTREE_PYTHON_TEST_USERNAME: testuser + INVENTREE_PYTHON_TEST_PASSWORD: testpassword steps: - name: Checkout Code @@ -140,13 +150,14 @@ jobs: git clone --depth 1 https://github.com/inventree/${{ env.wrapper_name }} ./${{ env.wrapper_name }} - name: Start Server run: | - invoke import-records -f ./${{ env.wrapper_name }}/test/test_data.json - invoke server -a 127.0.0.1:8000 & - sleep ${{ env.server_start_sleep }} + invoke delete-data -f + invoke import-fixtures + invoke server -a 127.0.0.1:12345 & - name: Run Tests run: | cd ${{ env.wrapper_name }} - invoke test + invoke check-server + coverage run -m unittest -s test/ coverage: name: Sqlite / coverage From 21d2b54afef6c7f4f03ff1ad9a1fb42ed6ddeafa Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 12 May 2022 11:00:43 +1000 Subject: [PATCH 03/16] Fix CI step --- .github/workflows/qc_checks.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index 3de8c3d808..b3ad9e24d2 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -157,7 +157,7 @@ jobs: run: | cd ${{ env.wrapper_name }} invoke check-server - coverage run -m unittest -s test/ + coverage run -m unittest discover -s test/ coverage: name: Sqlite / coverage From 774bfdb9e7b392c21bc181543544cc3b849430b4 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 12 May 2022 11:22:34 +1000 Subject: [PATCH 04/16] 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 05/16] 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 06/16] 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 07/16] 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 08/16] 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 09/16] 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 10/16] 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 11/16] 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 = [ From 47ddafb72890ecfbf4480b8a2605687f6221c0e3 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 12 May 2022 15:15:51 +1000 Subject: [PATCH 12/16] Fix edge case when converting stock item to variant - If the stock item had been created as part of a Build Order, and subsequently "converted" to a variant part, the conversion operation will fail - Patch allows the build reference to be linked based on either the base part, or any conversion options --- InvenTree/stock/models.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 53e1321e1a..a46d43b007 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -556,7 +556,14 @@ class StockItem(MPTTModel): # If the item points to a build, check that the Part references match if self.build: - if not self.part == self.build.part: + + if self.part == self.build.part: + # Part references match exactly + pass + elif self.part in self.build.part.get_conversion_options(): + # Part reference is one of the valid conversion options for the build output + pass + else: raise ValidationError({ 'build': _("Build reference does not point to the same part object") }) From 59cf9825fe9d11adbc124a758dc4e505a1828e94 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 12 May 2022 15:16:26 +1000 Subject: [PATCH 13/16] Update comment --- InvenTree/part/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 492da8f5de..96ffa581f4 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -2233,7 +2233,7 @@ class Part(MPTTModel): for child in children: parts.append(child) - # Immediate parent + # Immediate parent, and siblings if self.variant_of: parts.append(self.variant_of) From e112d555d403be244871c04bd5fd9b9d0288fe22 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 12 May 2022 16:45:27 +1000 Subject: [PATCH 14/16] Simplify the various settings objects, to improve retrieval of 'parameters' from the base class - Remove the GenericReferencedSettingsClass mixin - Each subclass defines a very simple get_kwargs() method - Now, at object level *and* class level we can perform lookup of settings and actually get proper data back - Adds "model" option to setting (precursor of things to come) --- InvenTree/common/models.py | 163 +++++++----------- InvenTree/common/serializers.py | 5 + InvenTree/plugin/models.py | 43 +++-- .../plugin/samples/integration/sample.py | 5 + .../templates/InvenTree/settings/setting.html | 3 + 5 files changed, 105 insertions(+), 114 deletions(-) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 11157763cb..37a6289d75 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -136,6 +136,19 @@ class BaseInvenTreeSetting(models.Model): return settings + def get_kwargs(self): + """ + Construct kwargs for doing class-based settings lookup, + depending on *which* class we are. + + This is necessary to abtract the settings object + from the implementing class (e.g plugins) + + Subclasses should override this function to ensure the kwargs are correctly set. + """ + + return {} + @classmethod def get_setting_definition(cls, key, **kwargs): """ @@ -319,11 +332,11 @@ class BaseInvenTreeSetting(models.Model): value = setting.value # Cast to boolean if necessary - if setting.is_bool(**kwargs): + if setting.is_bool(): value = InvenTree.helpers.str2bool(value) # Cast to integer if necessary - if setting.is_int(**kwargs): + if setting.is_int(): try: value = int(value) except (ValueError, TypeError): @@ -390,19 +403,19 @@ class BaseInvenTreeSetting(models.Model): @property def name(self): - return self.__class__.get_setting_name(self.key) + return self.__class__.get_setting_name(self.key, **self.get_kwargs()) @property def default_value(self): - return self.__class__.get_setting_default(self.key) + return self.__class__.get_setting_default(self.key, **self.get_kwargs()) @property def description(self): - return self.__class__.get_setting_description(self.key) + return self.__class__.get_setting_description(self.key, **self.get_kwargs()) @property def units(self): - return self.__class__.get_setting_units(self.key) + return self.__class__.get_setting_units(self.key, **self.get_kwargs()) def clean(self, **kwargs): """ @@ -512,12 +525,12 @@ class BaseInvenTreeSetting(models.Model): except self.DoesNotExist: pass - def choices(self, **kwargs): + def choices(self): """ Return the available choices for this setting (or None if no choices are defined) """ - return self.__class__.get_setting_choices(self.key, **kwargs) + return self.__class__.get_setting_choices(self.key, **self.get_kwargs()) def valid_options(self): """ @@ -531,14 +544,14 @@ class BaseInvenTreeSetting(models.Model): return [opt[0] for opt in choices] - def is_choice(self, **kwargs): + def is_choice(self): """ Check if this setting is a "choice" field """ - return self.__class__.get_setting_choices(self.key, **kwargs) is not None + return self.__class__.get_setting_choices(self.key, **self.get_kwargs()) is not None - def as_choice(self, **kwargs): + def as_choice(self): """ Render this setting as the "display" value of a choice field, e.g. if the choices are: @@ -547,7 +560,7 @@ class BaseInvenTreeSetting(models.Model): then display 'A4 paper' """ - choices = self.get_setting_choices(self.key, **kwargs) + choices = self.get_setting_choices(self.key, **self.get_kwargs()) if not choices: return self.value @@ -558,12 +571,28 @@ class BaseInvenTreeSetting(models.Model): return self.value - def is_bool(self, **kwargs): + def is_model(self): + """ + Check if this setting references a model instance in the database + """ + + return self.model_name() is not None + + def model_name(self): + """ + Return the model name associated with this setting + """ + + setting = self.get_setting_definition(self.key, **self.get_kwargs()) + + return setting.get('model', None) + + def is_bool(self): """ Check if this setting is required to be a boolean value """ - validator = self.__class__.get_setting_validator(self.key, **kwargs) + validator = self.__class__.get_setting_validator(self.key, **self.get_kwargs()) return self.__class__.validator_is_bool(validator) @@ -576,17 +605,20 @@ class BaseInvenTreeSetting(models.Model): return InvenTree.helpers.str2bool(self.value) - def setting_type(self, **kwargs): + def setting_type(self): """ Return the field type identifier for this setting object """ - if self.is_bool(**kwargs): + if self.is_bool(): return 'boolean' - elif self.is_int(**kwargs): + elif self.is_int(): return 'integer' + elif self.is_model(): + return 'model' + else: return 'string' @@ -603,12 +635,12 @@ class BaseInvenTreeSetting(models.Model): return False - def is_int(self, **kwargs): + def is_int(self,): """ Check if the setting is required to be an integer value: """ - validator = self.__class__.get_setting_validator(self.key, **kwargs) + validator = self.__class__.get_setting_validator(self.key, **self.get_kwargs()) return self.__class__.validator_is_int(validator) @@ -651,88 +683,7 @@ class BaseInvenTreeSetting(models.Model): @property def protected(self): - return self.__class__.is_protected(self.key) - - -class GenericReferencedSettingClass: - """ - This mixin can be used to add reference keys to static properties - - Sample: - ```python - class SampleSetting(GenericReferencedSettingClass, common.models.BaseInvenTreeSetting): - class Meta: - unique_together = [ - ('sample', 'key'), - ] - - REFERENCE_NAME = 'sample' - - @classmethod - def get_setting_definition(cls, key, **kwargs): - # mysampledict contains the dict with all settings for this SettingClass - this could also be a dynamic lookup - - kwargs['settings'] = mysampledict - return super().get_setting_definition(key, **kwargs) - - sample = models.charKey( # the name for this field is the additonal key and must be set in the Meta class an REFERENCE_NAME - max_length=256, - verbose_name=_('sample') - ) - ``` - """ - - REFERENCE_NAME = None - - def _get_reference(self): - """ - Returns dict that can be used as an argument for kwargs calls. - Helps to make overriden calls generic for simple reuse. - - Usage: - ```python - some_random_function(argument0, kwarg1=value1, **self._get_reference()) - ``` - """ - return { - self.REFERENCE_NAME: getattr(self, self.REFERENCE_NAME) - } - - """ - We override the following class methods, - so that we can pass the modified key instance as an additional argument - """ - - def clean(self, **kwargs): - - kwargs[self.REFERENCE_NAME] = getattr(self, self.REFERENCE_NAME) - - super().clean(**kwargs) - - def is_bool(self, **kwargs): - - kwargs[self.REFERENCE_NAME] = getattr(self, self.REFERENCE_NAME) - - return super().is_bool(**kwargs) - - @property - def name(self): - return self.__class__.get_setting_name(self.key, **self._get_reference()) - - @property - def default_value(self): - return self.__class__.get_setting_default(self.key, **self._get_reference()) - - @property - def description(self): - return self.__class__.get_setting_description(self.key, **self._get_reference()) - - @property - def units(self): - return self.__class__.get_setting_units(self.key, **self._get_reference()) - - def choices(self): - return self.__class__.get_setting_choices(self.key, **self._get_reference()) + return self.__class__.is_protected(self.key, **self.get_kwargs()) def settings_group_options(): @@ -1558,6 +1509,16 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): return self.__class__.get_setting(self.key, user=self.user) + def get_kwargs(self): + """ + Explicit kwargs required to uniquely identify a particular setting object, + in addition to the 'key' parameter + """ + + return { + 'user': self.user, + } + class PriceBreak(models.Model): """ diff --git a/InvenTree/common/serializers.py b/InvenTree/common/serializers.py index 9d637c3e39..27fc15bca5 100644 --- a/InvenTree/common/serializers.py +++ b/InvenTree/common/serializers.py @@ -28,6 +28,8 @@ class SettingsSerializer(InvenTreeModelSerializer): choices = serializers.SerializerMethodField() + model_name = serializers.CharField(read_only=True) + def get_choices(self, obj): """ Returns the choices available for a given item @@ -75,6 +77,7 @@ class GlobalSettingsSerializer(SettingsSerializer): 'description', 'type', 'choices', + 'model_name', ] @@ -96,6 +99,7 @@ class UserSettingsSerializer(SettingsSerializer): 'user', 'type', 'choices', + 'model_name', ] @@ -124,6 +128,7 @@ class GenericReferencedSettingSerializer(SettingsSerializer): 'description', 'type', 'choices', + 'model_name', ] # set Meta class diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py index 0624693abc..1620bed230 100644 --- a/InvenTree/plugin/models.py +++ b/InvenTree/plugin/models.py @@ -102,7 +102,7 @@ class PluginConfig(models.Model): return ret -class PluginSetting(common.models.GenericReferencedSettingClass, common.models.BaseInvenTreeSetting): +class PluginSetting(common.models.BaseInvenTreeSetting): """ This model represents settings for individual plugins """ @@ -112,7 +112,13 @@ class PluginSetting(common.models.GenericReferencedSettingClass, common.models.B ('plugin', 'key'), ] - REFERENCE_NAME = 'plugin' + plugin = models.ForeignKey( + PluginConfig, + related_name='settings', + null=False, + verbose_name=_('Plugin'), + on_delete=models.CASCADE, + ) @classmethod def get_setting_definition(cls, key, **kwargs): @@ -131,7 +137,7 @@ class PluginSetting(common.models.GenericReferencedSettingClass, common.models.B if 'settings' not in kwargs: - plugin = kwargs.pop('plugin', None) + plugin = kwargs.pop('plugin') if plugin: @@ -142,16 +148,18 @@ class PluginSetting(common.models.GenericReferencedSettingClass, common.models.B return super().get_setting_definition(key, **kwargs) - plugin = models.ForeignKey( - PluginConfig, - related_name='settings', - null=False, - verbose_name=_('Plugin'), - on_delete=models.CASCADE, - ) + def get_kwargs(self): + """ + Explicit kwargs required to uniquely identify a particular setting object, + in addition to the 'key' parameter + """ + + return { + 'plugin': self.plugin, + } -class NotificationUserSetting(common.models.GenericReferencedSettingClass, common.models.BaseInvenTreeSetting): +class NotificationUserSetting(common.models.BaseInvenTreeSetting): """ This model represents notification settings for a user """ @@ -161,8 +169,6 @@ class NotificationUserSetting(common.models.GenericReferencedSettingClass, commo ('method', 'user', 'key'), ] - REFERENCE_NAME = 'method' - @classmethod def get_setting_definition(cls, key, **kwargs): from common.notifications import storage @@ -171,6 +177,17 @@ class NotificationUserSetting(common.models.GenericReferencedSettingClass, commo return super().get_setting_definition(key, **kwargs) + def get_kwargs(self): + """ + Explicit kwargs required to uniquely identify a particular setting object, + in addition to the 'key' parameter + """ + + return { + 'method': self.method, + 'user': self.user, + } + method = models.CharField( max_length=255, verbose_name=_('Method'), diff --git a/InvenTree/plugin/samples/integration/sample.py b/InvenTree/plugin/samples/integration/sample.py index 2df3bc116a..af99727ed6 100644 --- a/InvenTree/plugin/samples/integration/sample.py +++ b/InvenTree/plugin/samples/integration/sample.py @@ -65,6 +65,11 @@ class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixi ], 'default': 'A', }, + 'SELECT_COMPANY': { + 'name': 'Company', + 'description': 'Select a company object from the database', + 'model': 'company.Company', + }, } NAVIGATION = [ diff --git a/InvenTree/templates/InvenTree/settings/setting.html b/InvenTree/templates/InvenTree/settings/setting.html index 0bc099f8a2..55c323faec 100644 --- a/InvenTree/templates/InvenTree/settings/setting.html +++ b/InvenTree/templates/InvenTree/settings/setting.html @@ -22,6 +22,9 @@ {{ setting.description }} + {% if setting.model_name %} + Model name: {{ setting.model_name }} + {% endif %} {% if setting.is_bool %}
From a81ea01e8e250d8b47a884b7419603c0a07b1109 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 12 May 2022 17:28:55 +1000 Subject: [PATCH 15/16] Model introspection - Find the class registered to the model (or log an error) - Pass the api_url through to the frontend --- InvenTree/common/models.py | 61 +++++++++++++++++-- InvenTree/common/serializers.py | 5 ++ InvenTree/plugin/models.py | 2 +- .../plugin/samples/integration/sample.py | 7 ++- .../templates/InvenTree/settings/setting.html | 3 - InvenTree/templates/js/dynamic/settings.js | 15 +++++ 6 files changed, 84 insertions(+), 9 deletions(-) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 37a6289d75..a13bbec071 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -17,15 +17,16 @@ import base64 from secrets import compare_digest from datetime import datetime, timedelta +from django.apps import apps from django.db import models, transaction +from django.db.utils import IntegrityError, OperationalError +from django.conf import settings from django.contrib.auth.models import User, Group from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType -from django.db.utils import IntegrityError, OperationalError -from django.conf import settings +from django.contrib.humanize.templatetags.humanize import naturaltime from django.urls import reverse from django.utils.timezone import now -from django.contrib.humanize.templatetags.humanize import naturaltime from djmoney.settings import CURRENCY_CHOICES from djmoney.contrib.exchange.models import convert_money @@ -587,6 +588,58 @@ class BaseInvenTreeSetting(models.Model): return setting.get('model', None) + def model_class(self): + """ + Return the model class associated with this setting, if (and only if): + + - It has a defined 'model' parameter + - The 'model' parameter is of the form app.model + - The 'model' parameter has matches a known app model + """ + + model_name = self.model_name() + + if not model_name: + return None + + try: + (app, mdl) = model_name.strip().split('.') + except ValueError: + logger.error(f"Invalid 'model' parameter for setting {self.key} : '{model_name}'") + return None + + app_models = apps.all_models.get(app, None) + + if app_models is None: + logger.error(f"Error retrieving model class '{model_name}' for setting '{self.key}' - no app named '{app}'") + return None + + model = app_models.get(mdl, None) + + if model is None: + logger.error(f"Error retrieving model class '{model_name}' for setting '{self.key}' - no model named '{mdl}'") + return None + + # Looks like we have found a model! + return model + + def api_url(self): + """ + Return the API url associated with the linked model, + if provided, and valid! + """ + + model_class = self.model_class() + + if model_class: + # If a valid class has been found, see if it has registered an API URL + try: + return model_class.get_api_url() + except: + pass + + return None + def is_bool(self): """ Check if this setting is required to be a boolean value @@ -617,7 +670,7 @@ class BaseInvenTreeSetting(models.Model): return 'integer' elif self.is_model(): - return 'model' + return 'related field' else: return 'string' diff --git a/InvenTree/common/serializers.py b/InvenTree/common/serializers.py index 27fc15bca5..8dd0f5bcee 100644 --- a/InvenTree/common/serializers.py +++ b/InvenTree/common/serializers.py @@ -30,6 +30,8 @@ class SettingsSerializer(InvenTreeModelSerializer): model_name = serializers.CharField(read_only=True) + api_url = serializers.CharField(read_only=True) + def get_choices(self, obj): """ Returns the choices available for a given item @@ -78,6 +80,7 @@ class GlobalSettingsSerializer(SettingsSerializer): 'type', 'choices', 'model_name', + 'api_url', ] @@ -100,6 +103,7 @@ class UserSettingsSerializer(SettingsSerializer): 'type', 'choices', 'model_name', + 'api_url', ] @@ -129,6 +133,7 @@ class GenericReferencedSettingSerializer(SettingsSerializer): 'type', 'choices', 'model_name', + 'api_url', ] # set Meta class diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py index 1620bed230..18320cc34b 100644 --- a/InvenTree/plugin/models.py +++ b/InvenTree/plugin/models.py @@ -137,7 +137,7 @@ class PluginSetting(common.models.BaseInvenTreeSetting): if 'settings' not in kwargs: - plugin = kwargs.pop('plugin') + plugin = kwargs.pop('plugin', None) if plugin: diff --git a/InvenTree/plugin/samples/integration/sample.py b/InvenTree/plugin/samples/integration/sample.py index af99727ed6..a3a26e7609 100644 --- a/InvenTree/plugin/samples/integration/sample.py +++ b/InvenTree/plugin/samples/integration/sample.py @@ -68,7 +68,12 @@ class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixi 'SELECT_COMPANY': { 'name': 'Company', 'description': 'Select a company object from the database', - 'model': 'company.Company', + 'model': 'company.company', + }, + 'SELECT_PART': { + 'name': 'Part', + 'description': 'Select a part object from the database', + 'model': 'part.part', }, } diff --git a/InvenTree/templates/InvenTree/settings/setting.html b/InvenTree/templates/InvenTree/settings/setting.html index 55c323faec..0bc099f8a2 100644 --- a/InvenTree/templates/InvenTree/settings/setting.html +++ b/InvenTree/templates/InvenTree/settings/setting.html @@ -22,9 +22,6 @@ {{ setting.description }} - {% if setting.model_name %} - Model name: {{ setting.model_name }} - {% endif %} {% if setting.is_bool %}
diff --git a/InvenTree/templates/js/dynamic/settings.js b/InvenTree/templates/js/dynamic/settings.js index 21eb9df5e2..5b52c2a015 100644 --- a/InvenTree/templates/js/dynamic/settings.js +++ b/InvenTree/templates/js/dynamic/settings.js @@ -71,9 +71,24 @@ function editSetting(key, options={}) { help_text: response.description, type: response.type, choices: response.choices, + value: response.value, } }; + // Foreign key lookup available! + if (response.type == 'related field') { + + if (response.model_name && response.api_url) { + fields.value.type = 'related field'; + fields.value.model = response.model_name.split('.').at(-1); + fields.value.api_url = response.api_url; + } else { + // Unknown / unsupported model type, default to 'text' field + fields.value.type = 'text'; + console.warn(`Unsupported model type: '${response.model_name}' for setting '${response.key}'`); + } + } + constructChangeForm(fields, { url: url, method: 'PATCH', From c4fa72e54c7ef0fa2651549a1f65a96fae9a7d52 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 12 May 2022 17:30:52 +1000 Subject: [PATCH 16/16] PEP style fixes --- InvenTree/common/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index a13bbec071..2ec83c962c 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -144,7 +144,7 @@ class BaseInvenTreeSetting(models.Model): This is necessary to abtract the settings object from the implementing class (e.g plugins) - + Subclasses should override this function to ensure the kwargs are correctly set. """ @@ -601,7 +601,7 @@ class BaseInvenTreeSetting(models.Model): if not model_name: return None - + try: (app, mdl) = model_name.strip().split('.') except ValueError: @@ -637,7 +637,7 @@ class BaseInvenTreeSetting(models.Model): return model_class.get_api_url() except: pass - + return None def is_bool(self):