diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index 0aabd064bc..b3ad9e24d2 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 discover -s test/ coverage: name: Sqlite / coverage diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py index a8b2903a90..9d901516d5 100644 --- a/InvenTree/InvenTree/api.py +++ b/InvenTree/InvenTree/api.py @@ -57,6 +57,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 and export_format in ['csv', 'tsv', 'xls', 'xlsx']: + 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, 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 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 diff --git a/InvenTree/build/admin.py b/InvenTree/build/admin.py index a612ad8460..43909d2197 100644 --- a/InvenTree/build/admin.py +++ b/InvenTree/build/admin.py @@ -2,9 +2,53 @@ 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/common/models.py b/InvenTree/common/models.py index 44895af091..8b50d05413 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 @@ -136,6 +137,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 +333,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 +404,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 +526,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 +545,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 +561,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 +572,80 @@ 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 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 """ - 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 +658,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 'related field' + else: return 'string' @@ -603,12 +688,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 +736,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 +1562,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..8dd0f5bcee 100644 --- a/InvenTree/common/serializers.py +++ b/InvenTree/common/serializers.py @@ -28,6 +28,10 @@ class SettingsSerializer(InvenTreeModelSerializer): choices = serializers.SerializerMethodField() + 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 @@ -75,6 +79,8 @@ class GlobalSettingsSerializer(SettingsSerializer): 'description', 'type', 'choices', + 'model_name', + 'api_url', ] @@ -96,6 +102,8 @@ class UserSettingsSerializer(SettingsSerializer): 'user', 'type', 'choices', + 'model_name', + 'api_url', ] @@ -124,6 +132,8 @@ class GenericReferencedSettingSerializer(SettingsSerializer): 'description', 'type', 'choices', + 'model_name', + 'api_url', ] # set Meta class diff --git a/InvenTree/order/admin.py b/InvenTree/order/admin.py index 0de28d5668..a1e1b74256 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 """ @@ -117,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 2dab7684de..7c8a93125f 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -17,10 +17,11 @@ 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 +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 @@ -110,7 +111,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 +161,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 @@ -407,7 +417,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 +470,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: @@ -580,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. @@ -630,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/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/part/api.py b/InvenTree/part/api.py index ca9accf9e2..622ca38669 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,14 @@ 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 +916,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: 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) diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py index 50e07df0d6..3d2d143eea 100644 --- a/InvenTree/plugin/models.py +++ b/InvenTree/plugin/models.py @@ -106,7 +106,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 """ @@ -116,7 +116,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): @@ -146,16 +152,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 """ @@ -165,8 +173,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 @@ -175,6 +181,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 38ed259522..e2f10fcabe 100644 --- a/InvenTree/plugin/samples/integration/sample.py +++ b/InvenTree/plugin/samples/integration/sample.py @@ -65,6 +65,16 @@ class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixi ], 'default': 'A', }, + 'SELECT_COMPANY': { + 'name': 'Company', + 'description': 'Select a company object from the database', + 'model': 'company.company', + }, + 'SELECT_PART': { + 'name': 'Part', + 'description': 'Select a part object from the database', + 'model': 'part.part', + }, } NAVIGATION = [ 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: 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") }) 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', 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', diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 538f37a710..372dc70a9a 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" %}', @@ -2091,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,