mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 05:05:42 +00:00 
			
		
		
		
	Merge branch 'master' of https://github.com/inventree/InvenTree into plugin-app-refactor
This commit is contained in:
		
							
								
								
									
										19
									
								
								.github/workflows/qc_checks.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										19
									
								
								.github/workflows/qc_checks.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -124,6 +124,16 @@ jobs: | |||||||
|  |  | ||||||
|     env: |     env: | ||||||
|       wrapper_name: inventree-python |       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: |     steps: | ||||||
|       - name: Checkout Code |       - name: Checkout Code | ||||||
| @@ -140,13 +150,14 @@ jobs: | |||||||
|           git clone --depth 1 https://github.com/inventree/${{ env.wrapper_name }} ./${{ env.wrapper_name }} |           git clone --depth 1 https://github.com/inventree/${{ env.wrapper_name }} ./${{ env.wrapper_name }} | ||||||
|       - name: Start Server |       - name: Start Server | ||||||
|         run: | |         run: | | ||||||
|           invoke import-records -f ./${{ env.wrapper_name }}/test/test_data.json |           invoke delete-data -f | ||||||
|           invoke server -a 127.0.0.1:8000 & |           invoke import-fixtures | ||||||
|           sleep ${{ env.server_start_sleep }} |           invoke server -a 127.0.0.1:12345 & | ||||||
|       - name: Run Tests |       - name: Run Tests | ||||||
|         run: | |         run: | | ||||||
|           cd ${{ env.wrapper_name }} |           cd ${{ env.wrapper_name }} | ||||||
|           invoke test |           invoke check-server | ||||||
|  |           coverage run -m unittest discover -s test/ | ||||||
|  |  | ||||||
|   coverage: |   coverage: | ||||||
|     name: Sqlite / coverage |     name: Sqlite / coverage | ||||||
|   | |||||||
| @@ -57,6 +57,44 @@ class NotFoundView(AjaxView): | |||||||
|         return JsonResponse(data, status=404) |         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=<fmt> 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: | class AttachmentMixin: | ||||||
|     """ |     """ | ||||||
|     Mixin for creating attachment objects, |     Mixin for creating attachment objects, | ||||||
|   | |||||||
| @@ -4,11 +4,16 @@ InvenTree API version information | |||||||
|  |  | ||||||
|  |  | ||||||
| # InvenTree API version | # 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 | 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 | 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 StockItem which does not exist | ||||||
|     - Fixes barcode API error response when scanning a StockLocation which does not exist |     - Fixes barcode API error response when scanning a StockLocation which does not exist | ||||||
|   | |||||||
| @@ -1,9 +1,12 @@ | |||||||
| from django.shortcuts import HttpResponseRedirect | # -*- coding: utf-8 -*- | ||||||
| from django.urls import reverse_lazy, Resolver404 |  | ||||||
| from django.shortcuts import redirect |  | ||||||
| from django.urls import include, re_path |  | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.contrib.auth.middleware import PersistentRemoteUserMiddleware | 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 | import logging | ||||||
|  |  | ||||||
| @@ -82,11 +85,23 @@ class AuthRequiredMiddleware(object): | |||||||
|                     reverse_lazy('admin:logout'), |                     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 |                     # Save the 'next' parameter to pass through to the login view | ||||||
|  |  | ||||||
|                     return redirect('{}?next={}'.format(reverse_lazy('account_login'), request.path)) |                     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) |         response = self.get_response(request) | ||||||
|  |  | ||||||
|         return response |         return response | ||||||
|   | |||||||
| @@ -2,9 +2,53 @@ | |||||||
| from __future__ import unicode_literals | from __future__ import unicode_literals | ||||||
|  |  | ||||||
| from django.contrib import admin | 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): | class BuildAdmin(ImportExportModelAdmin): | ||||||
|   | |||||||
| @@ -12,13 +12,15 @@ from rest_framework import filters, generics | |||||||
| from django_filters.rest_framework import DjangoFilterBackend | from django_filters.rest_framework import DjangoFilterBackend | ||||||
| from django_filters import rest_framework as rest_filters | from django_filters import rest_framework as rest_filters | ||||||
|  |  | ||||||
| from InvenTree.api import AttachmentMixin | from InvenTree.api import AttachmentMixin, APIDownloadMixin | ||||||
| from InvenTree.helpers import str2bool, isNull | from InvenTree.helpers import str2bool, isNull, DownloadFile | ||||||
| from InvenTree.filters import InvenTreeOrderingFilter | from InvenTree.filters import InvenTreeOrderingFilter | ||||||
| from InvenTree.status_codes import BuildStatus | from InvenTree.status_codes import BuildStatus | ||||||
|  |  | ||||||
| from .models import Build, BuildItem, BuildOrderAttachment | import build.admin | ||||||
| import build.serializers | import build.serializers | ||||||
|  | from build.models import Build, BuildItem, BuildOrderAttachment | ||||||
|  |  | ||||||
| from users.models import Owner | from users.models import Owner | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -71,7 +73,7 @@ class BuildFilter(rest_filters.FilterSet): | |||||||
|         return queryset |         return queryset | ||||||
|  |  | ||||||
|  |  | ||||||
| class BuildList(generics.ListCreateAPIView): | class BuildList(APIDownloadMixin, generics.ListCreateAPIView): | ||||||
|     """ API endpoint for accessing a list of Build objects. |     """ API endpoint for accessing a list of Build objects. | ||||||
|  |  | ||||||
|     - GET: Return list of objects (with filters) |     - GET: Return list of objects (with filters) | ||||||
| @@ -123,6 +125,14 @@ class BuildList(generics.ListCreateAPIView): | |||||||
|  |  | ||||||
|         return queryset |         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): |     def filter_queryset(self, queryset): | ||||||
|  |  | ||||||
|         queryset = super().filter_queryset(queryset) |         queryset = super().filter_queryset(queryset) | ||||||
|   | |||||||
| @@ -17,15 +17,16 @@ import base64 | |||||||
| from secrets import compare_digest | from secrets import compare_digest | ||||||
| from datetime import datetime, timedelta | from datetime import datetime, timedelta | ||||||
|  |  | ||||||
|  | from django.apps import apps | ||||||
| from django.db import models, transaction | 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.auth.models import User, Group | ||||||
| from django.contrib.contenttypes.fields import GenericForeignKey | from django.contrib.contenttypes.fields import GenericForeignKey | ||||||
| from django.contrib.contenttypes.models import ContentType | from django.contrib.contenttypes.models import ContentType | ||||||
| from django.db.utils import IntegrityError, OperationalError | from django.contrib.humanize.templatetags.humanize import naturaltime | ||||||
| from django.conf import settings |  | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from django.utils.timezone import now | from django.utils.timezone import now | ||||||
| from django.contrib.humanize.templatetags.humanize import naturaltime |  | ||||||
|  |  | ||||||
| from djmoney.settings import CURRENCY_CHOICES | from djmoney.settings import CURRENCY_CHOICES | ||||||
| from djmoney.contrib.exchange.models import convert_money | from djmoney.contrib.exchange.models import convert_money | ||||||
| @@ -136,6 +137,19 @@ class BaseInvenTreeSetting(models.Model): | |||||||
|  |  | ||||||
|         return settings |         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 |     @classmethod | ||||||
|     def get_setting_definition(cls, key, **kwargs): |     def get_setting_definition(cls, key, **kwargs): | ||||||
|         """ |         """ | ||||||
| @@ -319,11 +333,11 @@ class BaseInvenTreeSetting(models.Model): | |||||||
|             value = setting.value |             value = setting.value | ||||||
|  |  | ||||||
|             # Cast to boolean if necessary |             # Cast to boolean if necessary | ||||||
|             if setting.is_bool(**kwargs): |             if setting.is_bool(): | ||||||
|                 value = InvenTree.helpers.str2bool(value) |                 value = InvenTree.helpers.str2bool(value) | ||||||
|  |  | ||||||
|             # Cast to integer if necessary |             # Cast to integer if necessary | ||||||
|             if setting.is_int(**kwargs): |             if setting.is_int(): | ||||||
|                 try: |                 try: | ||||||
|                     value = int(value) |                     value = int(value) | ||||||
|                 except (ValueError, TypeError): |                 except (ValueError, TypeError): | ||||||
| @@ -390,19 +404,19 @@ class BaseInvenTreeSetting(models.Model): | |||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def name(self): |     def name(self): | ||||||
|         return self.__class__.get_setting_name(self.key) |         return self.__class__.get_setting_name(self.key, **self.get_kwargs()) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def default_value(self): |     def default_value(self): | ||||||
|         return self.__class__.get_setting_default(self.key) |         return self.__class__.get_setting_default(self.key, **self.get_kwargs()) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def description(self): |     def description(self): | ||||||
|         return self.__class__.get_setting_description(self.key) |         return self.__class__.get_setting_description(self.key, **self.get_kwargs()) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def units(self): |     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): |     def clean(self, **kwargs): | ||||||
|         """ |         """ | ||||||
| @@ -512,12 +526,12 @@ class BaseInvenTreeSetting(models.Model): | |||||||
|         except self.DoesNotExist: |         except self.DoesNotExist: | ||||||
|             pass |             pass | ||||||
|  |  | ||||||
|     def choices(self, **kwargs): |     def choices(self): | ||||||
|         """ |         """ | ||||||
|         Return the available choices for this setting (or None if no choices are defined) |         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): |     def valid_options(self): | ||||||
|         """ |         """ | ||||||
| @@ -531,14 +545,14 @@ class BaseInvenTreeSetting(models.Model): | |||||||
|  |  | ||||||
|         return [opt[0] for opt in choices] |         return [opt[0] for opt in choices] | ||||||
|  |  | ||||||
|     def is_choice(self, **kwargs): |     def is_choice(self): | ||||||
|         """ |         """ | ||||||
|         Check if this setting is a "choice" field |         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, |         Render this setting as the "display" value of a choice field, | ||||||
|         e.g. if the choices are: |         e.g. if the choices are: | ||||||
| @@ -547,7 +561,7 @@ class BaseInvenTreeSetting(models.Model): | |||||||
|         then display 'A4 paper' |         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: |         if not choices: | ||||||
|             return self.value |             return self.value | ||||||
| @@ -558,12 +572,80 @@ class BaseInvenTreeSetting(models.Model): | |||||||
|  |  | ||||||
|         return self.value |         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 |         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) |         return self.__class__.validator_is_bool(validator) | ||||||
|  |  | ||||||
| @@ -576,17 +658,20 @@ class BaseInvenTreeSetting(models.Model): | |||||||
|  |  | ||||||
|         return InvenTree.helpers.str2bool(self.value) |         return InvenTree.helpers.str2bool(self.value) | ||||||
|  |  | ||||||
|     def setting_type(self, **kwargs): |     def setting_type(self): | ||||||
|         """ |         """ | ||||||
|         Return the field type identifier for this setting object |         Return the field type identifier for this setting object | ||||||
|         """ |         """ | ||||||
|  |  | ||||||
|         if self.is_bool(**kwargs): |         if self.is_bool(): | ||||||
|             return 'boolean' |             return 'boolean' | ||||||
|  |  | ||||||
|         elif self.is_int(**kwargs): |         elif self.is_int(): | ||||||
|             return 'integer' |             return 'integer' | ||||||
|  |  | ||||||
|  |         elif self.is_model(): | ||||||
|  |             return 'related field' | ||||||
|  |  | ||||||
|         else: |         else: | ||||||
|             return 'string' |             return 'string' | ||||||
|  |  | ||||||
| @@ -603,12 +688,12 @@ class BaseInvenTreeSetting(models.Model): | |||||||
|  |  | ||||||
|         return False |         return False | ||||||
|  |  | ||||||
|     def is_int(self, **kwargs): |     def is_int(self,): | ||||||
|         """ |         """ | ||||||
|         Check if the setting is required to be an integer value: |         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) |         return self.__class__.validator_is_int(validator) | ||||||
|  |  | ||||||
| @@ -651,88 +736,7 @@ class BaseInvenTreeSetting(models.Model): | |||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def protected(self): |     def protected(self): | ||||||
|         return self.__class__.is_protected(self.key) |         return self.__class__.is_protected(self.key, **self.get_kwargs()) | ||||||
|  |  | ||||||
|  |  | ||||||
| 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()) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def settings_group_options(): | def settings_group_options(): | ||||||
| @@ -1558,6 +1562,16 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): | |||||||
|  |  | ||||||
|         return self.__class__.get_setting(self.key, user=self.user) |         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): | class PriceBreak(models.Model): | ||||||
|     """ |     """ | ||||||
|   | |||||||
| @@ -28,6 +28,10 @@ class SettingsSerializer(InvenTreeModelSerializer): | |||||||
|  |  | ||||||
|     choices = serializers.SerializerMethodField() |     choices = serializers.SerializerMethodField() | ||||||
|  |  | ||||||
|  |     model_name = serializers.CharField(read_only=True) | ||||||
|  |  | ||||||
|  |     api_url = serializers.CharField(read_only=True) | ||||||
|  |  | ||||||
|     def get_choices(self, obj): |     def get_choices(self, obj): | ||||||
|         """ |         """ | ||||||
|         Returns the choices available for a given item |         Returns the choices available for a given item | ||||||
| @@ -75,6 +79,8 @@ class GlobalSettingsSerializer(SettingsSerializer): | |||||||
|             'description', |             'description', | ||||||
|             'type', |             'type', | ||||||
|             'choices', |             'choices', | ||||||
|  |             'model_name', | ||||||
|  |             'api_url', | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -96,6 +102,8 @@ class UserSettingsSerializer(SettingsSerializer): | |||||||
|             'user', |             'user', | ||||||
|             'type', |             'type', | ||||||
|             'choices', |             'choices', | ||||||
|  |             'model_name', | ||||||
|  |             'api_url', | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -124,6 +132,8 @@ class GenericReferencedSettingSerializer(SettingsSerializer): | |||||||
|                 'description', |                 'description', | ||||||
|                 'type', |                 'type', | ||||||
|                 'choices', |                 'choices', | ||||||
|  |                 'model_name', | ||||||
|  |                 'api_url', | ||||||
|             ] |             ] | ||||||
|  |  | ||||||
|         # set Meta class |         # set Meta class | ||||||
|   | |||||||
| @@ -5,8 +5,9 @@ from django.contrib import admin | |||||||
|  |  | ||||||
| from import_export.admin import ImportExportModelAdmin | from import_export.admin import ImportExportModelAdmin | ||||||
|  |  | ||||||
| from import_export.resources import ModelResource |  | ||||||
| from import_export.fields import Field | 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 PurchaseOrder, PurchaseOrderLineItem, PurchaseOrderExtraLine | ||||||
| from .models import SalesOrder, SalesOrderLineItem, SalesOrderExtraLine | from .models import SalesOrder, SalesOrderLineItem, SalesOrderExtraLine | ||||||
| @@ -92,6 +93,23 @@ class SalesOrderAdmin(ImportExportModelAdmin): | |||||||
|     autocomplete_fields = ('customer',) |     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 PurchaseOrderLineItemResource(ModelResource): | ||||||
|     """ Class for managing import / export of PurchaseOrderLineItem data """ |     """ Class for managing import / export of PurchaseOrderLineItem data """ | ||||||
|  |  | ||||||
| @@ -117,6 +135,23 @@ class PurchaseOrderExtraLineResource(ModelResource): | |||||||
|         model = PurchaseOrderExtraLine |         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 SalesOrderLineItemResource(ModelResource): | ||||||
|     """ |     """ | ||||||
|     Class for managing import / export of SalesOrderLineItem data |     Class for managing import / export of SalesOrderLineItem data | ||||||
|   | |||||||
| @@ -17,10 +17,11 @@ from company.models import SupplierPart | |||||||
|  |  | ||||||
| from InvenTree.filters import InvenTreeOrderingFilter | from InvenTree.filters import InvenTreeOrderingFilter | ||||||
| from InvenTree.helpers import str2bool, DownloadFile | 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 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.models as models | ||||||
| import order.serializers as serializers | import order.serializers as serializers | ||||||
| from part.models import Part | 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 |     """ API endpoint for accessing a list of PurchaseOrder objects | ||||||
|  |  | ||||||
|     - GET: Return list of PurchaseOrder objects (with filters) |     - GET: Return list of PurchaseOrder objects (with filters) | ||||||
| @@ -160,6 +161,15 @@ class PurchaseOrderList(generics.ListCreateAPIView): | |||||||
|  |  | ||||||
|         return queryset |         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): |     def filter_queryset(self, queryset): | ||||||
|  |  | ||||||
|         # Perform basic filtering |         # Perform basic filtering | ||||||
| @@ -407,7 +417,7 @@ class PurchaseOrderLineItemFilter(rest_filters.FilterSet): | |||||||
|         return queryset |         return queryset | ||||||
|  |  | ||||||
|  |  | ||||||
| class PurchaseOrderLineItemList(generics.ListCreateAPIView): | class PurchaseOrderLineItemList(APIDownloadMixin, generics.ListCreateAPIView): | ||||||
|     """ API endpoint for accessing a list of PurchaseOrderLineItem objects |     """ API endpoint for accessing a list of PurchaseOrderLineItem objects | ||||||
|  |  | ||||||
|     - GET: Return a list of PurchaseOrder Line Item objects |     - GET: Return a list of PurchaseOrder Line Item objects | ||||||
| @@ -460,25 +470,19 @@ class PurchaseOrderLineItemList(generics.ListCreateAPIView): | |||||||
|  |  | ||||||
|         return queryset |         return queryset | ||||||
|  |  | ||||||
|     def list(self, request, *args, **kwargs): |     def download_queryset(self, queryset, export_format): | ||||||
|  |  | ||||||
|         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) |         dataset = PurchaseOrderLineItemResource().export(queryset=queryset) | ||||||
|  |  | ||||||
|         filedata = dataset.export(export_format) |         filedata = dataset.export(export_format) | ||||||
|  |  | ||||||
|                 filename = f"InvenTree_PurchaseOrderData.{export_format}" |         filename = f"InvenTree_PurchaseOrderItems.{export_format}" | ||||||
|  |  | ||||||
|         return DownloadFile(filedata, filename) |         return DownloadFile(filedata, filename) | ||||||
|  |  | ||||||
|  |     def list(self, request, *args, **kwargs): | ||||||
|  |  | ||||||
|  |         queryset = self.filter_queryset(self.get_queryset()) | ||||||
|  |  | ||||||
|         page = self.paginate_queryset(queryset) |         page = self.paginate_queryset(queryset) | ||||||
|  |  | ||||||
|         if page is not None: |         if page is not None: | ||||||
| @@ -580,7 +584,7 @@ class SalesOrderAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, Attachme | |||||||
|     serializer_class = serializers.SalesOrderAttachmentSerializer |     serializer_class = serializers.SalesOrderAttachmentSerializer | ||||||
|  |  | ||||||
|  |  | ||||||
| class SalesOrderList(generics.ListCreateAPIView): | class SalesOrderList(APIDownloadMixin, generics.ListCreateAPIView): | ||||||
|     """ |     """ | ||||||
|     API endpoint for accessing a list of SalesOrder objects. |     API endpoint for accessing a list of SalesOrder objects. | ||||||
|  |  | ||||||
| @@ -630,6 +634,15 @@ class SalesOrderList(generics.ListCreateAPIView): | |||||||
|  |  | ||||||
|         return queryset |         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): |     def filter_queryset(self, queryset): | ||||||
|         """ |         """ | ||||||
|         Perform custom filtering operations on the SalesOrder queryset. |         Perform custom filtering operations on the SalesOrder queryset. | ||||||
|   | |||||||
| @@ -4,8 +4,8 @@ from __future__ import unicode_literals | |||||||
| from django.contrib import admin | from django.contrib import admin | ||||||
|  |  | ||||||
| from import_export.admin import ImportExportModelAdmin | from import_export.admin import ImportExportModelAdmin | ||||||
| from import_export.resources import ModelResource |  | ||||||
| from import_export.fields import Field | from import_export.fields import Field | ||||||
|  | from import_export.resources import ModelResource | ||||||
| import import_export.widgets as widgets | import import_export.widgets as widgets | ||||||
|  |  | ||||||
| from company.models import SupplierPart | from company.models import SupplierPart | ||||||
|   | |||||||
| @@ -49,7 +49,7 @@ from . import serializers as part_serializers | |||||||
|  |  | ||||||
| from InvenTree.helpers import str2bool, isNull, increment | from InvenTree.helpers import str2bool, isNull, increment | ||||||
| from InvenTree.helpers import DownloadFile | from InvenTree.helpers import DownloadFile | ||||||
| from InvenTree.api import AttachmentMixin | from InvenTree.api import AttachmentMixin, APIDownloadMixin | ||||||
|  |  | ||||||
| from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus | from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus | ||||||
|  |  | ||||||
| @@ -847,7 +847,7 @@ class PartFilter(rest_filters.FilterSet): | |||||||
|     virtual = rest_filters.BooleanFilter() |     virtual = rest_filters.BooleanFilter() | ||||||
|  |  | ||||||
|  |  | ||||||
| class PartList(generics.ListCreateAPIView): | class PartList(APIDownloadMixin, generics.ListCreateAPIView): | ||||||
|     """ |     """ | ||||||
|     API endpoint for accessing a list of Part objects |     API endpoint for accessing a list of Part objects | ||||||
|  |  | ||||||
| @@ -897,6 +897,14 @@ class PartList(generics.ListCreateAPIView): | |||||||
|  |  | ||||||
|         return self.serializer_class(*args, **kwargs) |         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): |     def list(self, request, *args, **kwargs): | ||||||
|         """ |         """ | ||||||
|         Overide the 'list' method, as the PartCategory objects are |         Overide the 'list' method, as the PartCategory objects are | ||||||
| @@ -908,22 +916,6 @@ class PartList(generics.ListCreateAPIView): | |||||||
|  |  | ||||||
|         queryset = self.filter_queryset(self.get_queryset()) |         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) |         page = self.paginate_queryset(queryset) | ||||||
|  |  | ||||||
|         if page is not None: |         if page is not None: | ||||||
|   | |||||||
| @@ -2233,7 +2233,7 @@ class Part(MPTTModel): | |||||||
|         for child in children: |         for child in children: | ||||||
|             parts.append(child) |             parts.append(child) | ||||||
|  |  | ||||||
|         # Immediate parent |         # Immediate parent, and siblings | ||||||
|         if self.variant_of: |         if self.variant_of: | ||||||
|             parts.append(self.variant_of) |             parts.append(self.variant_of) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -106,7 +106,7 @@ class PluginConfig(models.Model): | |||||||
|         return ret |         return ret | ||||||
|  |  | ||||||
|  |  | ||||||
| class PluginSetting(common.models.GenericReferencedSettingClass, common.models.BaseInvenTreeSetting): | class PluginSetting(common.models.BaseInvenTreeSetting): | ||||||
|     """ |     """ | ||||||
|     This model represents settings for individual plugins |     This model represents settings for individual plugins | ||||||
|     """ |     """ | ||||||
| @@ -116,7 +116,13 @@ class PluginSetting(common.models.GenericReferencedSettingClass, common.models.B | |||||||
|             ('plugin', 'key'), |             ('plugin', 'key'), | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|     REFERENCE_NAME = 'plugin' |     plugin = models.ForeignKey( | ||||||
|  |         PluginConfig, | ||||||
|  |         related_name='settings', | ||||||
|  |         null=False, | ||||||
|  |         verbose_name=_('Plugin'), | ||||||
|  |         on_delete=models.CASCADE, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def get_setting_definition(cls, key, **kwargs): |     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) |         return super().get_setting_definition(key, **kwargs) | ||||||
|  |  | ||||||
|     plugin = models.ForeignKey( |     def get_kwargs(self): | ||||||
|         PluginConfig, |         """ | ||||||
|         related_name='settings', |         Explicit kwargs required to uniquely identify a particular setting object, | ||||||
|         null=False, |         in addition to the 'key' parameter | ||||||
|         verbose_name=_('Plugin'), |         """ | ||||||
|         on_delete=models.CASCADE, |  | ||||||
|     ) |         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 |     This model represents notification settings for a user | ||||||
|     """ |     """ | ||||||
| @@ -165,8 +173,6 @@ class NotificationUserSetting(common.models.GenericReferencedSettingClass, commo | |||||||
|             ('method', 'user', 'key'), |             ('method', 'user', 'key'), | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|     REFERENCE_NAME = 'method' |  | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def get_setting_definition(cls, key, **kwargs): |     def get_setting_definition(cls, key, **kwargs): | ||||||
|         from common.notifications import storage |         from common.notifications import storage | ||||||
| @@ -175,6 +181,17 @@ class NotificationUserSetting(common.models.GenericReferencedSettingClass, commo | |||||||
|  |  | ||||||
|         return super().get_setting_definition(key, **kwargs) |         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( |     method = models.CharField( | ||||||
|         max_length=255, |         max_length=255, | ||||||
|         verbose_name=_('Method'), |         verbose_name=_('Method'), | ||||||
|   | |||||||
| @@ -65,6 +65,16 @@ class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixi | |||||||
|             ], |             ], | ||||||
|             'default': 'A', |             '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 = [ |     NAVIGATION = [ | ||||||
|   | |||||||
| @@ -33,7 +33,7 @@ from company.serializers import CompanySerializer, SupplierPartSerializer | |||||||
|  |  | ||||||
| from InvenTree.helpers import str2bool, isNull, extract_serial_numbers | from InvenTree.helpers import str2bool, isNull, extract_serial_numbers | ||||||
| from InvenTree.helpers import DownloadFile | from InvenTree.helpers import DownloadFile | ||||||
| from InvenTree.api import AttachmentMixin | from InvenTree.api import AttachmentMixin, APIDownloadMixin | ||||||
| from InvenTree.filters import InvenTreeOrderingFilter | from InvenTree.filters import InvenTreeOrderingFilter | ||||||
|  |  | ||||||
| from order.models import PurchaseOrder | 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') |     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 |     """ API endpoint for list view of Stock objects | ||||||
|  |  | ||||||
|     - GET: Return a list of all StockItem objects (with optional query filters) |     - 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)) |             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): |     def list(self, request, *args, **kwargs): | ||||||
|         """ |         """ | ||||||
|         Override the 'list' method, as the StockLocation objects |         Override the 'list' method, as the StockLocation objects | ||||||
| @@ -658,25 +674,6 @@ class StockList(generics.ListCreateAPIView): | |||||||
|  |  | ||||||
|         params = request.query_params |         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) |         page = self.paginate_queryset(queryset) | ||||||
|  |  | ||||||
|         if page is not None: |         if page is not None: | ||||||
|   | |||||||
| @@ -556,7 +556,14 @@ class StockItem(MPTTModel): | |||||||
|  |  | ||||||
|         # If the item points to a build, check that the Part references match |         # If the item points to a build, check that the Part references match | ||||||
|         if self.build: |         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({ |                 raise ValidationError({ | ||||||
|                     'build': _("Build reference does not point to the same part object") |                     'build': _("Build reference does not point to the same part object") | ||||||
|                 }) |                 }) | ||||||
|   | |||||||
| @@ -71,9 +71,24 @@ function editSetting(key, options={}) { | |||||||
|                     help_text: response.description, |                     help_text: response.description, | ||||||
|                     type: response.type, |                     type: response.type, | ||||||
|                     choices: response.choices, |                     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, { |             constructChangeForm(fields, { | ||||||
|                 url: url, |                 url: url, | ||||||
|                 method: 'PATCH', |                 method: 'PATCH', | ||||||
|   | |||||||
| @@ -2355,7 +2355,7 @@ function loadBuildTable(table, options) { | |||||||
|  |  | ||||||
|     var filterTarget = options.filterTarget || null; |     var filterTarget = options.filterTarget || null; | ||||||
|  |  | ||||||
|     setupFilterList('build', table, filterTarget); |     setupFilterList('build', table, filterTarget, {download: true}); | ||||||
|  |  | ||||||
|     $(table).inventreeTable({ |     $(table).inventreeTable({ | ||||||
|         method: 'get', |         method: 'get', | ||||||
|   | |||||||
| @@ -1394,7 +1394,9 @@ function loadPurchaseOrderTable(table, options) { | |||||||
|         filters[key] = options.params[key]; |         filters[key] = options.params[key]; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     setupFilterList('purchaseorder', $(table)); |     var target = '#filter-list-purchaseorder'; | ||||||
|  |  | ||||||
|  |     setupFilterList('purchaseorder', $(table), target, {download: true}); | ||||||
|  |  | ||||||
|     $(table).inventreeTable({ |     $(table).inventreeTable({ | ||||||
|         url: '{% url "api-po-list" %}', |         url: '{% url "api-po-list" %}', | ||||||
| @@ -2091,7 +2093,9 @@ function loadSalesOrderTable(table, options) { | |||||||
|  |  | ||||||
|     options.url = options.url || '{% url "api-so-list" %}'; |     options.url = options.url || '{% url "api-so-list" %}'; | ||||||
|  |  | ||||||
|     setupFilterList('salesorder', $(table)); |     var target = '#filter-list-salesorder'; | ||||||
|  |  | ||||||
|  |     setupFilterList('salesorder', $(table), target, {download: true}); | ||||||
|  |  | ||||||
|     $(table).inventreeTable({ |     $(table).inventreeTable({ | ||||||
|         url: options.url, |         url: options.url, | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user