From a92442e60e23be0ff5dcf42d222b0d95823ecb9b Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Sun, 7 Jan 2024 20:58:23 +0100 Subject: [PATCH] added autofixes --- InvenTree/InvenTree/admin.py | 17 +- InvenTree/InvenTree/api.py | 73 +- InvenTree/InvenTree/api_version.py | 1 - InvenTree/InvenTree/apps.py | 48 +- InvenTree/InvenTree/ci_render_js.py | 19 +- InvenTree/InvenTree/config.py | 63 +- InvenTree/InvenTree/context.py | 8 +- InvenTree/InvenTree/conversion.py | 5 +- InvenTree/InvenTree/email.py | 2 +- InvenTree/InvenTree/exchange.py | 28 +- InvenTree/InvenTree/fields.py | 17 +- InvenTree/InvenTree/filters.py | 14 +- InvenTree/InvenTree/format.py | 30 +- InvenTree/InvenTree/forms.py | 87 +- InvenTree/InvenTree/helpers.py | 127 +- InvenTree/InvenTree/helpers_model.py | 84 +- InvenTree/InvenTree/magic_login.py | 11 +- .../management/commands/prerender.py | 11 +- .../management/commands/rebuild_models.py | 5 + .../management/commands/remove_mfa.py | 7 +- .../management/commands/wait_for_db.py | 1 - InvenTree/InvenTree/metadata.py | 39 +- InvenTree/InvenTree/middleware.py | 37 +- InvenTree/InvenTree/mixins.py | 33 +- InvenTree/InvenTree/models.py | 218 +-- InvenTree/InvenTree/permissions.py | 8 +- InvenTree/InvenTree/ready.py | 4 +- InvenTree/InvenTree/sanitizer.py | 219 ++- InvenTree/InvenTree/sentry.py | 5 +- InvenTree/InvenTree/serializers.py | 134 +- InvenTree/InvenTree/settings.py | 520 ++++---- InvenTree/InvenTree/social_auth_urls.py | 60 +- InvenTree/InvenTree/sso.py | 1 - InvenTree/InvenTree/status.py | 4 +- InvenTree/InvenTree/status_codes.py | 82 +- InvenTree/InvenTree/tasks.py | 138 +- InvenTree/InvenTree/test_api.py | 83 +- InvenTree/InvenTree/test_api_version.py | 1 - InvenTree/InvenTree/test_middleware.py | 1 + InvenTree/InvenTree/test_tasks.py | 26 +- InvenTree/InvenTree/test_urls.py | 15 +- InvenTree/InvenTree/test_views.py | 32 +- InvenTree/InvenTree/tests.py | 274 ++-- InvenTree/InvenTree/translation.py | 2 +- InvenTree/InvenTree/unit_test.py | 60 +- InvenTree/InvenTree/urls.py | 412 ++++-- InvenTree/InvenTree/validators.py | 12 +- InvenTree/InvenTree/version.py | 15 +- InvenTree/InvenTree/views.py | 40 +- InvenTree/InvenTree/wsgi.py | 4 +- InvenTree/common/admin.py | 20 +- InvenTree/common/api.py | 360 ++--- InvenTree/common/apps.py | 8 +- InvenTree/common/files.py | 27 +- InvenTree/common/forms.py | 36 +- InvenTree/common/models.py | 906 ++++++------- InvenTree/common/notifications.py | 95 +- InvenTree/common/serializers.py | 51 +- InvenTree/common/settings.py | 4 +- InvenTree/common/tasks.py | 9 +- InvenTree/common/test_notifications.py | 41 +- InvenTree/common/test_tasks.py | 2 +- InvenTree/common/tests.py | 419 +++--- InvenTree/common/urls.py | 3 +- InvenTree/common/views.py | 71 +- InvenTree/company/admin.py | 68 +- InvenTree/company/api.py | 347 ++--- InvenTree/company/models.py | 449 ++++--- InvenTree/company/serializers.py | 142 +- InvenTree/company/test_api.py | 229 +--- InvenTree/company/test_migrations.py | 58 +- InvenTree/company/test_supplier_parts.py | 45 +- InvenTree/company/test_views.py | 4 +- InvenTree/company/tests.py | 75 +- InvenTree/company/urls.py | 38 +- InvenTree/company/views.py | 7 +- InvenTree/generic/states/__init__.py | 7 +- InvenTree/generic/states/api.py | 17 +- InvenTree/generic/states/states.py | 11 +- InvenTree/generic/states/test_transition.py | 8 +- InvenTree/generic/states/tests.py | 115 +- InvenTree/generic/states/transition.py | 15 +- InvenTree/generic/templatetags/generic.py | 4 +- InvenTree/label/admin.py | 1 + InvenTree/label/api.py | 235 +++- InvenTree/label/apps.py | 56 +- InvenTree/label/models.py | 74 +- InvenTree/label/serializers.py | 15 +- InvenTree/label/test_api.py | 30 +- InvenTree/label/tests.py | 19 +- InvenTree/order/admin.py | 174 +-- InvenTree/order/api.py | 812 ++++++++---- InvenTree/order/apps.py | 1 + InvenTree/order/forms.py | 18 +- InvenTree/order/models.py | 583 +++++--- InvenTree/order/serializers.py | 408 +++--- InvenTree/order/tasks.py | 47 +- InvenTree/order/test_api.py | 699 ++++------ InvenTree/order/test_migrations.py | 36 +- InvenTree/order/test_sales_order.py | 85 +- InvenTree/order/test_views.py | 6 +- InvenTree/order/tests.py | 31 +- InvenTree/order/urls.py | 8 - InvenTree/order/views.py | 70 +- InvenTree/part/admin.py | 312 +++-- InvenTree/part/api.py | 945 ++++++++----- InvenTree/part/apps.py | 17 +- InvenTree/part/bom.py | 64 +- InvenTree/part/filters.py | 80 +- InvenTree/part/forms.py | 25 +- InvenTree/part/helpers.py | 20 +- InvenTree/part/models.py | 1161 +++++++++------- InvenTree/part/part.py | 5 +- InvenTree/part/serializers.py | 605 +++++---- InvenTree/part/stocktake.py | 47 +- InvenTree/part/tasks.py | 44 +- InvenTree/part/templatetags/i18n.py | 25 +- .../part/templatetags/inventree_extras.py | 51 +- InvenTree/part/test_api.py | 1172 ++++++----------- InvenTree/part/test_bom_export.py | 11 +- InvenTree/part/test_bom_import.py | 146 +- InvenTree/part/test_bom_item.py | 105 +- InvenTree/part/test_category.py | 45 +- InvenTree/part/test_migrations.py | 37 +- InvenTree/part/test_param.py | 165 +-- InvenTree/part/test_part.py | 96 +- InvenTree/part/test_pricing.py | 76 +- InvenTree/part/test_views.py | 18 +- InvenTree/part/urls.py | 24 +- InvenTree/part/views.py | 155 ++- InvenTree/plugin/__init__.py | 1 - InvenTree/plugin/admin.py | 26 +- InvenTree/plugin/api.py | 175 ++- InvenTree/plugin/apps.py | 9 +- InvenTree/plugin/base/action/api.py | 13 +- InvenTree/plugin/base/action/test_action.py | 35 +- InvenTree/plugin/base/barcodes/api.py | 122 +- InvenTree/plugin/base/barcodes/mixins.py | 77 +- InvenTree/plugin/base/barcodes/serializers.py | 40 +- .../plugin/base/barcodes/test_barcode.py | 173 +-- InvenTree/plugin/base/event/events.py | 39 +- .../plugin/base/integration/APICallMixin.py | 21 +- InvenTree/plugin/base/integration/AppMixin.py | 11 +- .../base/integration/CurrencyExchangeMixin.py | 5 +- .../plugin/base/integration/ScheduleMixin.py | 35 +- .../plugin/base/integration/SettingsMixin.py | 12 +- .../plugin/base/integration/UrlsMixin.py | 14 +- .../base/integration/ValidationMixin.py | 2 +- InvenTree/plugin/base/integration/mixins.py | 19 +- .../plugin/base/integration/test_mixins.py | 81 +- InvenTree/plugin/base/label/label.py | 11 +- InvenTree/plugin/base/label/mixins.py | 33 +- .../plugin/base/label/test_label_mixin.py | 95 +- InvenTree/plugin/base/locate/api.py | 24 +- InvenTree/plugin/base/locate/mixins.py | 1 + InvenTree/plugin/base/locate/test_locate.py | 72 +- InvenTree/plugin/broken/broken_sample.py | 1 + .../barcodes/test_inventree_barcode.py | 206 +-- .../builtin/integration/core_notifications.py | 85 +- .../builtin/integration/currency_exchange.py | 15 +- .../integration/test_core_notifications.py | 7 +- .../plugin/builtin/labels/inventree_label.py | 10 +- .../plugin/builtin/labels/label_sheet.py | 37 +- InvenTree/plugin/builtin/suppliers/lcsc.py | 5 +- .../suppliers/test_supplier_barcodes.py | 93 +- InvenTree/plugin/builtin/suppliers/tme.py | 2 +- InvenTree/plugin/events.py | 9 +- InvenTree/plugin/helpers.py | 34 +- InvenTree/plugin/installer.py | 17 +- InvenTree/plugin/mixins/__init__.py | 10 +- InvenTree/plugin/models.py | 53 +- InvenTree/plugin/plugin.py | 39 +- InvenTree/plugin/registry.py | 158 ++- .../plugin/samples/event/test_event_sample.py | 5 +- .../event/test_filtered_event_sample.py | 9 +- .../samples/integration/another_sample.py | 1 + .../plugin/samples/integration/api_caller.py | 6 +- .../integration/custom_panel_sample.py | 7 +- .../samples/integration/label_sample.py | 1 + .../integration/report_plugin_sample.py | 4 +- .../plugin/samples/integration/sample.py | 17 +- .../integration/sample_currency_exchange.py | 4 +- .../samples/integration/scheduled_task.py | 8 +- .../samples/integration/simpleactionplugin.py | 5 +- .../samples/integration/test_api_caller.py | 2 +- .../plugin/samples/integration/test_sample.py | 1 + .../integration/test_scheduled_task.py | 46 +- .../integration/test_simpleactionplugin.py | 11 +- .../plugin/samples/integration/transition.py | 10 +- .../samples/integration/validation_sample.py | 6 +- .../plugin/samples/integration/version.py | 1 + .../plugin/samples/locate/locate_sample.py | 4 +- .../samples/locate/test_locate_sample.py | 8 +- InvenTree/plugin/serializers.py | 61 +- .../plugin/templatetags/plugin_extras.py | 19 +- InvenTree/plugin/test_api.py | 176 ++- InvenTree/plugin/test_helpers.py | 5 +- InvenTree/plugin/test_plugin.py | 26 +- InvenTree/plugin/urls.py | 4 +- InvenTree/plugin/views.py | 7 +- InvenTree/report/admin.py | 17 +- InvenTree/report/api.py | 390 ++++-- InvenTree/report/apps.py | 42 +- InvenTree/report/models.py | 88 +- InvenTree/report/serializers.py | 27 +- InvenTree/report/templatetags/barcode.py | 11 +- InvenTree/report/templatetags/report.py | 16 +- InvenTree/report/tests.py | 60 +- InvenTree/script/translation_stats.py | 7 +- InvenTree/stock/admin.py | 214 ++- InvenTree/stock/api.py | 639 +++++---- InvenTree/stock/apps.py | 1 + InvenTree/stock/filters.py | 2 +- InvenTree/stock/models.py | 521 ++++---- InvenTree/stock/serializers.py | 369 ++---- InvenTree/stock/test_api.py | 893 ++++++------- InvenTree/stock/test_migrations.py | 46 +- InvenTree/stock/test_views.py | 52 +- InvenTree/stock/tests.py | 218 ++- InvenTree/stock/urls.py | 21 +- InvenTree/stock/views.py | 20 +- InvenTree/users/admin.py | 83 +- InvenTree/users/api.py | 109 +- InvenTree/users/apps.py | 9 +- InvenTree/users/models.py | 164 +-- InvenTree/users/serializers.py | 13 +- InvenTree/users/test_api.py | 26 +- InvenTree/users/test_migrations.py | 12 +- InvenTree/users/tests.py | 27 +- InvenTree/web/templatetags/spa_helper.py | 25 +- InvenTree/web/tests.py | 17 +- InvenTree/web/urls.py | 18 +- ci/check_api_endpoint.py | 7 +- ci/check_js_templates.py | 6 - ci/version_check.py | 19 +- docs/docs/hooks.py | 26 +- docs/main.py | 6 +- tasks.py | 199 ++- 238 files changed, 11858 insertions(+), 10630 deletions(-) diff --git a/InvenTree/InvenTree/admin.py b/InvenTree/InvenTree/admin.py index a9b68ece4f..6da1d9a2d5 100644 --- a/InvenTree/InvenTree/admin.py +++ b/InvenTree/InvenTree/admin.py @@ -27,14 +27,18 @@ class InvenTreeResource(ModelResource): using_transactions, collect_failed_rows, rollback_on_validation_errors=None, - **kwargs + **kwargs, ): """Override the default import_data_inner function to provide better error handling""" if len(dataset) > self.MAX_IMPORT_ROWS: - raise ImportExportError(f"Dataset contains too many rows (max {self.MAX_IMPORT_ROWS})") + raise ImportExportError( + f"Dataset contains too many rows (max {self.MAX_IMPORT_ROWS})" + ) if len(dataset.headers) > self.MAX_IMPORT_COLS: - raise ImportExportError(f"Dataset contains too many columns (max {self.MAX_IMPORT_COLS})") + raise ImportExportError( + f"Dataset contains too many columns (max {self.MAX_IMPORT_COLS})" + ) return super().import_data_inner( dataset, @@ -43,7 +47,7 @@ class InvenTreeResource(ModelResource): using_transactions, collect_failed_rows, rollback_on_validation_errors=rollback_on_validation_errors, - **kwargs + **kwargs, ) def export_resource(self, obj): @@ -72,10 +76,7 @@ class InvenTreeResource(ModelResource): """Return fields, with some common exclusions""" fields = super().get_fields(**kwargs) - fields_to_exclude = [ - 'metadata', - 'lft', 'rght', 'tree_id', 'level', - ] + fields_to_exclude = ['metadata', 'lft', 'rght', 'tree_id', 'level'] return [f for f in fields if f.column_name not in fields_to_exclude] diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py index 2c47b338ad..24f9b58765 100644 --- a/InvenTree/InvenTree/api.py +++ b/InvenTree/InvenTree/api.py @@ -31,9 +31,7 @@ from .views import AjaxView class VersionView(APIView): """Simple JSON endpoint for InvenTree version information.""" - permission_classes = [ - permissions.IsAdminUser, - ] + permission_classes = [permissions.IsAdminUser] def get(self, request, *args, **kwargs): """Return information about the InvenTree server.""" @@ -47,20 +45,21 @@ class VersionView(APIView): 'commit_date': InvenTree.version.inventreeCommitDate(), 'commit_branch': InvenTree.version.inventreeBranch(), 'python': InvenTree.version.inventreePythonVersion(), - 'django': InvenTree.version.inventreeDjangoVersion() + 'django': InvenTree.version.inventreeDjangoVersion(), }, 'links': { 'doc': InvenTree.version.inventreeDocUrl(), 'code': InvenTree.version.inventreeGithubUrl(), 'credit': InvenTree.version.inventreeCreditsUrl(), 'app': InvenTree.version.inventreeAppUrl(), - 'bug': f'{InvenTree.version.inventreeGithubUrl()}/issues' - } + 'bug': f'{InvenTree.version.inventreeGithubUrl()}/issues', + }, }) class VersionSerializer(serializers.Serializer): """Serializer for a single version.""" + version = serializers.CharField() date = serializers.CharField() gh = serializers.CharField() @@ -69,16 +68,19 @@ class VersionSerializer(serializers.Serializer): class Meta: """Meta class for VersionSerializer.""" + fields = ['version', 'date', 'gh', 'text', 'latest'] class VersionApiSerializer(serializers.Serializer): """Serializer for the version api endpoint.""" + VersionSerializer(many=True) class VersionTextView(ListAPI): """Simple JSON endpoint for InvenTree version text.""" + permission_classes = [permissions.IsAdminUser] @extend_schema(responses={200: OpenApiResponse(response=VersionApiSerializer)}) @@ -119,10 +121,10 @@ class InfoView(AjaxView): 'debug_mode': settings.DEBUG, 'docker_mode': settings.DOCKER, 'system_health': check_system_health() if is_staff else None, - 'database': InvenTree.version.inventreeDatabase()if is_staff else None, + 'database': InvenTree.version.inventreeDatabase() if is_staff else None, 'platform': InvenTree.version.inventreePlatform() if is_staff else None, 'installer': InvenTree.version.inventreeInstaller() if is_staff else None, - 'target': InvenTree.version.inventreeTarget()if is_staff else None, + 'target': InvenTree.version.inventreeTarget() if is_staff else None, } return JsonResponse(data) @@ -130,7 +132,9 @@ class InfoView(AjaxView): def check_auth_header(self, request): """Check if user is authenticated via a token in the header.""" # TODO @matmair: remove after refacgtor of Token check is done - headers = request.headers.get('Authorization', request.headers.get('authorization')) + headers = request.headers.get( + 'Authorization', request.headers.get('authorization') + ) if not headers: return False @@ -160,7 +164,7 @@ class NotFoundView(AjaxView): 'detail': _('API endpoint not found'), 'url': request.build_absolute_uri(), }, - status=404 + status=404, ) def options(self, request, *args, **kwargs): @@ -228,7 +232,9 @@ class BulkDeleteMixin: if not items and not filters: raise ValidationError({ - "non_field_errors": ["List of items or filters must be provided for bulk deletion"], + "non_field_errors": [ + "List of items or filters must be provided for bulk deletion" + ] }) if items and type(items) is not list: @@ -245,7 +251,6 @@ class BulkDeleteMixin: n_deleted = 0 with transaction.atomic(): - # Start with *all* models and perform basic filtering queryset = model.objects.all() queryset = self.filter_delete_queryset(queryset, request) @@ -261,16 +266,12 @@ class BulkDeleteMixin: n_deleted = queryset.count() queryset.delete() - return Response( - { - 'success': f"Deleted {n_deleted} items", - }, - status=204 - ) + return Response({'success': f"Deleted {n_deleted} items"}, status=204) class ListCreateDestroyAPIView(BulkDeleteMixin, ListCreateAPI): """Custom API endpoint which provides BulkDelete functionality in addition to List and Create""" + ... @@ -313,18 +314,11 @@ class APIDownloadMixin: class AttachmentMixin: """Mixin for creating attachment objects, and ensuring the user information is saved correctly.""" - permission_classes = [ - permissions.IsAuthenticated, - RolePermission, - ] + permission_classes = [permissions.IsAuthenticated, RolePermission] filter_backends = SEARCH_ORDER_FILTER - search_fields = [ - 'attachment', - 'comment', - 'link', - ] + search_fields = ['attachment', 'comment', 'link'] def perform_create(self, serializer): """Save the user information when a file is uploaded.""" @@ -342,9 +336,7 @@ class APISearchView(APIView): Is much more efficient and simplifies code! """ - permission_classes = [ - permissions.IsAuthenticated, - ] + permission_classes = [permissions.IsAuthenticated] def get_result_types(self): """Construct a list of search types we can return""" @@ -384,14 +376,11 @@ class APISearchView(APIView): } if 'search' not in data: - raise ValidationError({ - 'search': 'Search term must be provided', - }) + raise ValidationError({'search': 'Search term must be provided'}) for key, cls in self.get_result_types().items(): # Only return results which are specifically requested if key in data: - params = data[key] for k, v in pass_through_params.items(): @@ -418,16 +407,18 @@ class APISearchView(APIView): table = f'{app_label}_{model_name}' try: - if users.models.RuleSet.check_table_permission(request.user, table, 'view'): + if users.models.RuleSet.check_table_permission( + request.user, table, 'view' + ): results[key] = view.list(request, *args, **kwargs).data else: results[key] = { - 'error': _('User does not have permission to view this model') + 'error': _( + 'User does not have permission to view this model' + ) } except Exception as exc: - results[key] = { - 'error': str(exc) - } + results[key] = {'error': str(exc)} return Response(results) @@ -442,7 +433,9 @@ class MetadataView(RetrieveUpdateAPI): model = self.kwargs.get(self.MODEL_REF, None) if model is None: - raise ValidationError(f"MetadataView called without '{self.MODEL_REF}' parameter") + raise ValidationError( + f"MetadataView called without '{self.MODEL_REF}' parameter" + ) return model diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 1a869bdd51..696a125e67 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -1,6 +1,5 @@ """InvenTree API version information.""" - # InvenTree API version INVENTREE_API_VERSION = 160 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index d1e32319c8..1f22d4e98d 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -14,14 +14,19 @@ from django.db.utils import IntegrityError, OperationalError import InvenTree.conversion import InvenTree.tasks from InvenTree.config import get_setting -from InvenTree.ready import (canAppAccessDatabase, isInMainThread, - isInTestMode, isPluginRegistryLoaded) +from InvenTree.ready import ( + canAppAccessDatabase, + isInMainThread, + isInTestMode, + isPluginRegistryLoaded, +) logger = logging.getLogger("inventree") class InvenTreeConfig(AppConfig): """AppConfig for inventree app.""" + name = 'InvenTree' def ready(self): @@ -41,7 +46,6 @@ class InvenTreeConfig(AppConfig): return if canAppAccessDatabase() or settings.TESTING_ENV: - self.remove_obsolete_tasks() self.collect_tasks() @@ -99,15 +103,16 @@ class InvenTreeConfig(AppConfig): tasks = InvenTree.tasks.tasks.task_list for task in tasks: - ref_name = f'{task.func.__module__}.{task.func.__name__}' if ref_name in existing_tasks.keys(): # This task already exists - update the details if required existing_task = existing_tasks[ref_name] - if existing_task.schedule_type != task.interval or existing_task.minutes != task.minutes: - + if ( + existing_task.schedule_type != task.interval + or existing_task.minutes != task.minutes + ): existing_task.schedule_type = task.interval existing_task.minutes = task.minutes tasks_to_update.append(existing_task) @@ -133,10 +138,7 @@ class InvenTreeConfig(AppConfig): # Put at least one task onto the background worker stack, # which will be processed as soon as the worker comes online - InvenTree.tasks.offload_task( - InvenTree.tasks.heartbeat, - force_async=True, - ) + InvenTree.tasks.offload_task(InvenTree.tasks.heartbeat, force_async=True) logger.info("Started %s scheduled background tasks...", len(tasks)) @@ -186,10 +188,14 @@ class InvenTreeConfig(AppConfig): # Backend currency has changed? if base_currency != backend.base_currency: - logger.info("Base currency changed from %s to %s", backend.base_currency, base_currency) + logger.info( + "Base currency changed from %s to %s", + backend.base_currency, + base_currency, + ) update = True - except (ExchangeBackend.DoesNotExist): + except ExchangeBackend.DoesNotExist: logger.info("Exchange backend not found - updating") update = True @@ -215,7 +221,9 @@ class InvenTreeConfig(AppConfig): add_user = get_setting('INVENTREE_ADMIN_USER', 'admin_user') add_email = get_setting('INVENTREE_ADMIN_EMAIL', 'admin_email') add_password = get_setting('INVENTREE_ADMIN_PASSWORD', 'admin_password') - add_password_file = get_setting("INVENTREE_ADMIN_PASSWORD_FILE", "admin_password_file", None) + add_password_file = get_setting( + "INVENTREE_ADMIN_PASSWORD_FILE", "admin_password_file", None + ) # check if all values are present set_variables = 0 @@ -236,7 +244,9 @@ class InvenTreeConfig(AppConfig): # if a password file is present, do not warn - will be handled later if add_password_file: return - logger.warning('Not all required settings for adding a user on startup are present:\nINVENTREE_ADMIN_USER, INVENTREE_ADMIN_EMAIL, INVENTREE_ADMIN_PASSWORD') + logger.warning( + 'Not all required settings for adding a user on startup are present:\nINVENTREE_ADMIN_USER, INVENTREE_ADMIN_EMAIL, INVENTREE_ADMIN_PASSWORD' + ) return # good to go -> create user @@ -252,7 +262,9 @@ class InvenTreeConfig(AppConfig): if user.objects.filter(username=add_user).exists(): logger.info("User %s already exists - skipping creation", add_user) else: - new_user = user.objects.create_superuser(add_user, add_email, add_password) + new_user = user.objects.create_superuser( + add_user, add_email, add_password + ) logger.info('User %s was created!', str(new_user)) except IntegrityError: logger.warning('The user "%s" could not be created', add_user) @@ -281,7 +293,11 @@ class InvenTreeConfig(AppConfig): return # good to go -> create user - self._create_admin_user(get_setting('INVENTREE_ADMIN_USER', 'admin_user', 'admin'), get_setting('INVENTREE_ADMIN_EMAIL', 'admin_email', ''), add_password_file.read_text(encoding="utf-8")) + self._create_admin_user( + get_setting('INVENTREE_ADMIN_USER', 'admin_user', 'admin'), + get_setting('INVENTREE_ADMIN_EMAIL', 'admin_email', ''), + add_password_file.read_text(encoding="utf-8"), + ) # do not try again settings.USER_ADDED_FILE = True diff --git a/InvenTree/InvenTree/ci_render_js.py b/InvenTree/InvenTree/ci_render_js.py index f221349e08..72e493c720 100644 --- a/InvenTree/InvenTree/ci_render_js.py +++ b/InvenTree/InvenTree/ci_render_js.py @@ -24,22 +24,14 @@ class RenderJavascriptFiles(InvenTreeTestCase): # pragma: no cover here = os.path.abspath(os.path.dirname(__file__)) - output_dir = os.path.join( - here, - '..', - '..', - 'js_tmp', - ) + output_dir = os.path.join(here, '..', '..', 'js_tmp') output_dir = os.path.abspath(output_dir) if not os.path.exists(output_dir): os.mkdir(output_dir) - output_file = os.path.join( - output_dir, - filename, - ) + output_file = os.path.join(output_dir, filename) with open(output_file, 'wb') as output: output.write(response.content) @@ -48,12 +40,7 @@ class RenderJavascriptFiles(InvenTreeTestCase): # pragma: no cover """Download files in directory.""" here = os.path.abspath(os.path.dirname(__file__)) - js_template_dir = os.path.join( - here, - '..', - 'templates', - 'js', - ) + js_template_dir = os.path.join(here, '..', 'templates', 'js') directory = os.path.join(js_template_dir, subdir) diff --git a/InvenTree/InvenTree/config.py b/InvenTree/InvenTree/config.py index 204b4376a0..609ea6e71d 100644 --- a/InvenTree/InvenTree/config.py +++ b/InvenTree/InvenTree/config.py @@ -51,7 +51,11 @@ def to_dict(value): try: return json.loads(value) except Exception as error: - logger.exception("Failed to parse value '%s' as JSON with error %s. Ensure value is a valid JSON string.", value, error) + logger.exception( + "Failed to parse value '%s' as JSON with error %s. Ensure value is a valid JSON string.", + value, + error, + ) return {} @@ -90,7 +94,9 @@ def get_config_file(create=True) -> Path: cfg_filename = base_dir.joinpath('config.yaml').resolve() if not cfg_filename.exists() and create: - print("InvenTree configuration file 'config.yaml' not found - creating default file") + print( + "InvenTree configuration file 'config.yaml' not found - creating default file" + ) ensure_dir(cfg_filename.parent) cfg_template = base_dir.joinpath("config_template.yaml") @@ -153,7 +159,13 @@ def do_typecast(value, type, var_name=None): return val except Exception as error: if var_name: - logger.exception("Failed to typecast '%s' with value '%s' to type '%s' with error %s", var_name, value, type, error) + logger.exception( + "Failed to typecast '%s' with value '%s' to type '%s' with error %s", + var_name, + value, + type, + error, + ) return value @@ -174,7 +186,12 @@ def get_setting(env_var=None, config_key=None, default_value=None, typecast=None def set_metadata(source: str): """Set lookup metadata for the setting.""" key = env_var or config_key - CONFIG_LOOKUPS[key] = {'env_var': env_var, 'config_key': config_key, 'source': source, 'accessed': datetime.datetime.now()} + CONFIG_LOOKUPS[key] = { + 'env_var': env_var, + 'config_key': config_key, + 'source': source, + 'accessed': datetime.datetime.now(), + } # First, try to load from the environment variables if env_var is not None: @@ -192,7 +209,6 @@ def get_setting(env_var=None, config_key=None, default_value=None, typecast=None # Hack to allow 'path traversal' in configuration file for key in config_key.strip().split('.'): - if type(cfg_data) is not dict or key not in cfg_data: result = None break @@ -276,12 +292,16 @@ def get_plugin_file(): plugin_file = Path(plugin_file) if not plugin_file.exists(): - logger.warning("Plugin configuration file does not exist - creating default file") + logger.warning( + "Plugin configuration file does not exist - creating default file" + ) logger.info("Creating plugin file at '%s'", plugin_file) ensure_dir(plugin_file.parent) # If opening the file fails (no write permission, for example), then this will throw an error - plugin_file.write_text("# InvenTree Plugins (uses PIP framework to install)\n\n") + plugin_file.write_text( + "# InvenTree Plugins (uses PIP framework to install)\n\n" + ) return plugin_file @@ -329,7 +349,9 @@ def get_secret_key(): return key_data -def get_custom_file(env_ref: str, conf_ref: str, log_ref: str, lookup_media: bool = False): +def get_custom_file( + env_ref: str, conf_ref: str, log_ref: str, lookup_media: bool = False +): """Returns the checked path to a custom file. Set lookup_media to True to also search in the media folder. @@ -350,7 +372,12 @@ def get_custom_file(env_ref: str, conf_ref: str, log_ref: str, lookup_media: boo logger.info("Loading %s from %s directory: %s", log_ref, 'media', value) else: add_dir_str = ' or media' if lookup_media else '' - logger.warning("The %s file '%s' could not be found in the static %s directories", log_ref, value, add_dir_str) + logger.warning( + "The %s file '%s' could not be found in the static %s directories", + log_ref, + value, + add_dir_str, + ) value = False return value @@ -364,16 +391,21 @@ def get_frontend_settings(debug=True): """ # Legacy settings - pui_settings = get_setting('INVENTREE_PUI_SETTINGS', 'pui_settings', {}, typecast=dict) + pui_settings = get_setting( + 'INVENTREE_PUI_SETTINGS', 'pui_settings', {}, typecast=dict + ) if len(pui_settings) > 0: warnings.warn( "The 'INVENTREE_PUI_SETTINGS' key is deprecated. Please use 'INVENTREE_FRONTEND_SETTINGS' instead", - DeprecationWarning, stacklevel=2 + DeprecationWarning, + stacklevel=2, ) # New settings - frontend_settings = get_setting('INVENTREE_FRONTEND_SETTINGS', 'frontend_settings', {}, typecast=dict) + frontend_settings = get_setting( + 'INVENTREE_FRONTEND_SETTINGS', 'frontend_settings', {}, typecast=dict + ) # Merge settings settings = {**pui_settings, **frontend_settings} @@ -385,10 +417,13 @@ def get_frontend_settings(debug=True): if base_url: warnings.warn( "The 'INVENTREE_PUI_URL_BASE' key is deprecated. Please use 'INVENTREE_FRONTEND_URL_BASE' instead", - DeprecationWarning, stacklevel=2 + DeprecationWarning, + stacklevel=2, ) else: - base_url = get_setting('INVENTREE_FRONTEND_URL_BASE', 'frontend_url_base', 'platform') + base_url = get_setting( + 'INVENTREE_FRONTEND_URL_BASE', 'frontend_url_base', 'platform' + ) settings['base_url'] = base_url diff --git a/InvenTree/InvenTree/context.py b/InvenTree/InvenTree/context.py index 4518d2b951..e25a27d668 100644 --- a/InvenTree/InvenTree/context.py +++ b/InvenTree/InvenTree/context.py @@ -31,9 +31,7 @@ def health_status(request): } # The following keys are required to denote system health - health_keys = [ - 'django_q_running', - ] + health_keys = ['django_q_running'] all_healthy = True @@ -72,11 +70,9 @@ def user_roles(request): """ user = request.user - roles = { - } + roles = {} for role in RuleSet.RULESET_MODELS.keys(): - permissions = {} for perm in ['view', 'add', 'change', 'delete']: diff --git a/InvenTree/InvenTree/conversion.py b/InvenTree/InvenTree/conversion.py index 6324848693..cba34ff460 100644 --- a/InvenTree/InvenTree/conversion.py +++ b/InvenTree/InvenTree/conversion.py @@ -29,6 +29,7 @@ def reload_unit_registry(): This function is called at startup, and whenever the database is updated. """ import time + t_start = time.time() global _unit_registry @@ -52,7 +53,9 @@ def reload_unit_registry(): try: reg.define(cu.fmt_string()) except Exception as e: - logger.exception('Failed to load custom unit: %s - %s', cu.fmt_string(), e) + logger.exception( + 'Failed to load custom unit: %s - %s', cu.fmt_string(), e + ) # Once custom units are loaded, save registry _unit_registry = reg diff --git a/InvenTree/InvenTree/email.py b/InvenTree/InvenTree/email.py index 9552262dd1..b93c39f63b 100644 --- a/InvenTree/InvenTree/email.py +++ b/InvenTree/InvenTree/email.py @@ -85,5 +85,5 @@ def send_email(subject, body, recipients, from_email=None, html_message=None): from_email, recipients, fail_silently=False, - html_message=html_message + html_message=html_message, ) diff --git a/InvenTree/InvenTree/exchange.py b/InvenTree/InvenTree/exchange.py index b3c6262d84..f9db24e5d8 100644 --- a/InvenTree/InvenTree/exchange.py +++ b/InvenTree/InvenTree/exchange.py @@ -44,7 +44,9 @@ class InvenTreeExchange(SimpleExchangeBackend): plugin = plugins[0] if not plugin: - logger.warning('No active currency exchange plugins found - skipping update') + logger.warning( + 'No active currency exchange plugins found - skipping update' + ) return {} logger.info("Running exchange rate update using plugin '%s'", plugin.name) @@ -57,12 +59,18 @@ class InvenTreeExchange(SimpleExchangeBackend): return {} if not rates: - logger.warning("Exchange rate update failed - no data returned from plugin %s", slug) + logger.warning( + "Exchange rate update failed - no data returned from plugin %s", slug + ) return {} # Update exchange rates based on returned data if type(rates) is not dict: - logger.warning("Invalid exchange rate data returned from plugin %s (type %s)", slug, type(rates)) + logger.warning( + "Invalid exchange rate data returned from plugin %s (type %s)", + slug, + type(rates), + ) return {} # Ensure base currency is provided @@ -73,14 +81,20 @@ class InvenTreeExchange(SimpleExchangeBackend): @atomic def update_rates(self, base_currency=None, **kwargs): """Call to update all exchange rates""" - backend, _ = ExchangeBackend.objects.update_or_create(name=self.name, defaults={"base_currency": base_currency}) + backend, _ = ExchangeBackend.objects.update_or_create( + name=self.name, defaults={"base_currency": base_currency} + ) if base_currency is None: base_currency = currency_code_default() symbols = currency_codes() - logger.info("Updating exchange rates for %s (%s currencies)", base_currency, len(symbols)) + logger.info( + "Updating exchange rates for %s (%s currencies)", + base_currency, + len(symbols), + ) # Fetch new rates from the backend # If the backend fails, the existing rates will not be updated @@ -95,6 +109,8 @@ class InvenTreeExchange(SimpleExchangeBackend): for currency, amount in rates.items() ]) else: - logger.info("No exchange rates returned from backend - currencies not updated") + logger.info( + "No exchange rates returned from backend - currencies not updated" + ) logger.info("Updated exchange rates for %s", base_currency) diff --git a/InvenTree/InvenTree/fields.py b/InvenTree/InvenTree/fields.py index 70ca133a80..b91bdc39a4 100644 --- a/InvenTree/InvenTree/fields.py +++ b/InvenTree/InvenTree/fields.py @@ -35,7 +35,9 @@ class InvenTreeRestURLField(RestURLField): import common.models - strict_urls = common.models.InvenTreeSetting.get_setting('INVENTREE_STRICT_URLS', True, cache=False) + strict_urls = common.models.InvenTreeSetting.get_setting( + 'INVENTREE_STRICT_URLS', True, cache=False + ) if not strict_urls and data is not empty: if '://' not in data: @@ -97,11 +99,8 @@ class InvenTreeModelMoneyField(ModelMoneyField): # If no validators are provided, add some "standard" ones if len(validators) == 0: - if not allow_negative: - validators.append( - MinMoneyValidator(0), - ) + validators.append(MinMoneyValidator(0)) kwargs['validators'] = validators @@ -144,11 +143,7 @@ class DatePickerFormField(forms.DateField): required = kwargs.get('required', False) initial = kwargs.get('initial', None) - widget = forms.DateInput( - attrs={ - 'type': 'date', - } - ) + widget = forms.DateInput(attrs={'type': 'date'}) forms.DateField.__init__( self, @@ -156,7 +151,7 @@ class DatePickerFormField(forms.DateField): initial=initial, help_text=help_text, widget=widget, - label=label + label=label, ) diff --git a/InvenTree/InvenTree/filters.py b/InvenTree/InvenTree/filters.py index cea9b01b03..38287a5667 100644 --- a/InvenTree/InvenTree/filters.py +++ b/InvenTree/InvenTree/filters.py @@ -36,7 +36,9 @@ class InvenTreeSearchFilter(filters.SearchFilter): The following query params are available to 'augment' the search (in decreasing order of priority) - search_regex: If True, search is performed on 'regex' comparison """ - regex = InvenTree.helpers.str2bool(request.query_params.get('search_regex', False)) + regex = InvenTree.helpers.str2bool( + request.query_params.get('search_regex', False) + ) search_fields = super().get_search_fields(view, request) @@ -56,7 +58,9 @@ class InvenTreeSearchFilter(filters.SearchFilter): Depending on the request parameters, we may "augment" these somewhat """ - whole = InvenTree.helpers.str2bool(request.query_params.get('search_whole', False)) + whole = InvenTree.helpers.str2bool( + request.query_params.get('search_whole', False) + ) terms = [] @@ -110,7 +114,6 @@ class InvenTreeOrderingFilter(filters.OrderingFilter): ordering = [] for field in ordering_initial: - reverse = field.startswith('-') if reverse: @@ -164,7 +167,4 @@ SEARCH_ORDER_FILTER_ALIAS = [ InvenTreeOrderingFilter, ] -ORDER_FILTER = [ - rest_filters.DjangoFilterBackend, - filters.OrderingFilter, -] +ORDER_FILTER = [rest_filters.DjangoFilterBackend, filters.OrderingFilter] diff --git a/InvenTree/InvenTree/format.py b/InvenTree/InvenTree/format.py index ab4267959f..b2cd45a23d 100644 --- a/InvenTree/InvenTree/format.py +++ b/InvenTree/InvenTree/format.py @@ -36,10 +36,7 @@ def parse_format_string(fmt_string: str) -> dict: else: seen_groups.add(name) - info[group[1]] = { - 'format': group[1], - 'prefix': group[0], - } + info[group[1]] = {'format': group[1], 'prefix': group[0]} return info @@ -75,9 +72,23 @@ def construct_format_regex(fmt_string: str) -> str: format = group[2] # Format specifier e.g :04d rep = [ - '+', '-', '.', - '{', '}', '(', ')', - '^', '$', '~', '!', '@', ':', ';', '|', '\'', '"', + '+', + '-', + '.', + '{', + '}', + '(', + ')', + '^', + '$', + '~', + '!', + '@', + ':', + ';', + '|', + '\'', + '"', ] # Escape any special regex characters @@ -94,7 +105,6 @@ def construct_format_regex(fmt_string: str) -> str: # Add a named capture group for the format entry if name: - # Check if integer values are required if format.endswith('d'): chr = '\d' @@ -161,7 +171,9 @@ def extract_named_group(name: str, value: str, fmt_string: str) -> str: result = re.match(pattern, value) if not result: - raise ValueError(_("Provided value does not match required pattern: ") + fmt_string) + raise ValueError( + _("Provided value does not match required pattern: ") + fmt_string + ) # And return the value we are interested in # Note: This will raise an IndexError if the named group was not matched diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py index 3532218d02..9ae110eea3 100644 --- a/InvenTree/InvenTree/forms.py +++ b/InvenTree/InvenTree/forms.py @@ -17,8 +17,7 @@ from allauth.core.exceptions import ImmediateHttpResponse from allauth.socialaccount.adapter import DefaultSocialAccountAdapter from allauth_2fa.adapter import OTPAdapter from allauth_2fa.utils import user_has_valid_totp_device -from crispy_forms.bootstrap import (AppendedText, PrependedAppendedText, - PrependedText) +from crispy_forms.bootstrap import AppendedText, PrependedAppendedText, PrependedText from crispy_forms.helper import FormHelper from crispy_forms.layout import Field, Layout from dj_rest_auth.registration.serializers import RegisterSerializer @@ -80,31 +79,19 @@ class HelperForm(forms.ModelForm): field, prepended_text=prefix, appended_text=suffix, - placeholder=placeholder + placeholder=placeholder, ) ) ) elif prefix: layouts.append( - Field( - PrependedText( - field, - prefix, - placeholder=placeholder - ) - ) + Field(PrependedText(field, prefix, placeholder=placeholder)) ) elif suffix: layouts.append( - Field( - AppendedText( - field, - suffix, - placeholder=placeholder - ) - ) + Field(AppendedText(field, suffix, placeholder=placeholder)) ) else: @@ -120,10 +107,7 @@ class EditUserForm(HelperForm): """Metaclass options.""" model = User - fields = [ - 'first_name', - 'last_name', - ] + fields = ['first_name', 'last_name'] class SetPasswordForm(HelperForm): @@ -133,11 +117,7 @@ class SetPasswordForm(HelperForm): """Metaclass options.""" model = User - fields = [ - 'enter_password', - 'confirm_password', - 'old_password', - ] + fields = ['enter_password', 'confirm_password', 'old_password'] enter_password = forms.CharField( max_length=100, @@ -146,7 +126,7 @@ class SetPasswordForm(HelperForm): initial='', widget=forms.PasswordInput(attrs={'autocomplete': 'off'}), label=_('Enter password'), - help_text=_('Enter new password') + help_text=_('Enter new password'), ) confirm_password = forms.CharField( @@ -156,14 +136,16 @@ class SetPasswordForm(HelperForm): initial='', widget=forms.PasswordInput(attrs={'autocomplete': 'off'}), label=_('Confirm password'), - help_text=_('Confirm new password') + help_text=_('Confirm new password'), ) old_password = forms.CharField( label=_("Old password"), strip=False, required=False, - widget=forms.PasswordInput(attrs={'autocomplete': 'current-password', 'autofocus': True}), + widget=forms.PasswordInput( + attrs={'autocomplete': 'current-password', 'autofocus': True} + ), ) @@ -211,7 +193,9 @@ class CustomSignupForm(SignupForm): self.fields.pop("password2") # reorder fields - set_form_field_order(self, ["username", "email", "email2", "password1", "password2", ]) + set_form_field_order( + self, ["username", "email", "email2", "password1", "password2"] + ) def clean(self): """Make sure the supplied emails match if enabled in settings.""" @@ -229,11 +213,16 @@ class CustomSignupForm(SignupForm): def registration_enabled(): """Determine whether user registration is enabled.""" - if InvenTreeSetting.get_setting('LOGIN_ENABLE_REG') or InvenTree.sso.registration_enabled(): + if ( + InvenTreeSetting.get_setting('LOGIN_ENABLE_REG') + or InvenTree.sso.registration_enabled() + ): if settings.EMAIL_HOST: return True else: - logger.error("Registration cannot be enabled, because EMAIL_HOST is not configured.") + logger.error( + "Registration cannot be enabled, because EMAIL_HOST is not configured." + ) return False @@ -251,20 +240,26 @@ class RegistratonMixin: def clean_email(self, email): """Check if the mail is valid to the pattern in LOGIN_SIGNUP_MAIL_RESTRICTION (if enabled in settings).""" - mail_restriction = InvenTreeSetting.get_setting('LOGIN_SIGNUP_MAIL_RESTRICTION', None) + mail_restriction = InvenTreeSetting.get_setting( + 'LOGIN_SIGNUP_MAIL_RESTRICTION', None + ) if not mail_restriction: return super().clean_email(email) split_email = email.split('@') if len(split_email) != 2: logger.error('The user %s has an invalid email address', email) - raise forms.ValidationError(_('The provided primary email address is not valid.')) + raise forms.ValidationError( + _('The provided primary email address is not valid.') + ) mailoptions = mail_restriction.split(',') for option in mailoptions: if not option.startswith('@'): log_error('LOGIN_SIGNUP_MAIL_RESTRICTION is not configured correctly') - raise forms.ValidationError(_('The provided primary email address is not valid.')) + raise forms.ValidationError( + _('The provided primary email address is not valid.') + ) else: if split_email[1] == option[1:]: return super().clean_email(email) @@ -284,7 +279,10 @@ class RegistratonMixin: group = Group.objects.get(id=start_group) user.groups.add(group) except Group.DoesNotExist: - logger.exception('The setting `SIGNUP_GROUP` contains an non existent group', start_group) + logger.exception( + 'The setting `SIGNUP_GROUP` contains an non existent group', + start_group, + ) user.save() return user @@ -298,7 +296,9 @@ class CustomUrlMixin: return Site.objects.get_current().domain + url -class CustomAccountAdapter(CustomUrlMixin, RegistratonMixin, OTPAdapter, DefaultAccountAdapter): +class CustomAccountAdapter( + CustomUrlMixin, RegistratonMixin, OTPAdapter, DefaultAccountAdapter +): """Override of adapter to use dynamic settings.""" def send_mail(self, template_prefix, email, context): @@ -325,7 +325,9 @@ class CustomAccountAdapter(CustomUrlMixin, RegistratonMixin, OTPAdapter, Default return url -class CustomSocialAccountAdapter(CustomUrlMixin, RegistratonMixin, DefaultSocialAccountAdapter): +class CustomSocialAccountAdapter( + CustomUrlMixin, RegistratonMixin, DefaultSocialAccountAdapter +): """Override of adapter to use dynamic settings.""" def is_auto_signup_allowed(self, request, sociallogin): @@ -352,14 +354,14 @@ class CustomSocialAccountAdapter(CustomUrlMixin, RegistratonMixin, DefaultSocial if request.GET: redirect_url += '?' + urlencode(request.GET) - raise ImmediateHttpResponse( - response=HttpResponseRedirect(redirect_url) - ) + raise ImmediateHttpResponse(response=HttpResponseRedirect(redirect_url)) # Otherwise defer to the original allauth adapter. return super().login(request, user) - def authentication_error(self, request, provider_id, error=None, exception=None, extra_context=None): + def authentication_error( + self, request, provider_id, error=None, exception=None, extra_context=None + ): """Callback method for authentication errors.""" # Log the error to the database @@ -370,6 +372,7 @@ class CustomSocialAccountAdapter(CustomUrlMixin, RegistratonMixin, DefaultSocial # override dj-rest-auth class CustomRegisterSerializer(RegisterSerializer): """Override of serializer to use dynamic settings.""" + email = serializers.EmailField() def __init__(self, instance=None, data=..., **kwargs): diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 2f81b68a75..6b332912bf 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -94,7 +94,6 @@ def getLogoImage(as_file=False, custom=True): """Return the InvenTree logo image, or a custom logo if available.""" """Return the path to the logo-file.""" if custom and settings.CUSTOM_LOGO: - static_storage = StaticFilesStorage() if static_storage.exists(settings.CUSTOM_LOGO): @@ -121,7 +120,6 @@ def getSplashScreen(custom=True): static_storage = StaticFilesStorage() if custom and settings.CUSTOM_SPLASH: - if static_storage.exists(settings.CUSTOM_SPLASH): return static_storage.url(settings.CUSTOM_SPLASH) @@ -135,10 +133,15 @@ def TestIfImageURL(url): Simply tests the extension against a set of allowed values """ return os.path.splitext(os.path.basename(url))[-1].lower() in [ - '.jpg', '.jpeg', '.j2k', - '.png', '.bmp', - '.tif', '.tiff', - '.webp', '.gif', + '.jpg', + '.jpeg', + '.j2k', + '.png', + '.bmp', + '.tif', + '.tiff', + '.webp', + '.gif', ] @@ -153,8 +156,8 @@ def str2bool(text, test=True): True if the text looks like the selected boolean value """ if test: - return str(text).lower() in ['1', 'y', 'yes', 't', 'true', 'ok', 'on', ] - return str(text).lower() in ['0', 'n', 'no', 'none', 'f', 'false', 'off', ] + return str(text).lower() in ['1', 'y', 'yes', 't', 'true', 'ok', 'on'] + return str(text).lower() in ['0', 'n', 'no', 'none', 'f', 'false', 'off'] def str2int(text, default=None): @@ -191,7 +194,15 @@ def isNull(text): Returns: True if the text looks like a null value """ - return str(text).strip().lower() in ['top', 'null', 'none', 'empty', 'false', '-1', ''] + return str(text).strip().lower() in [ + 'top', + 'null', + 'none', + 'empty', + 'false', + '-1', + '', + ] def normalize(d): @@ -354,17 +365,12 @@ def MakeBarcode(cls_name, object_pk: int, object_data=None, **kwargs): def GetExportFormats(): """Return a list of allowable file formats for exporting data.""" - return [ - 'csv', - 'tsv', - 'xls', - 'xlsx', - 'json', - 'yaml', - ] + return ['csv', 'tsv', 'xls', 'xlsx', 'json', 'yaml'] -def DownloadFile(data, filename, content_type='application/text', inline=False) -> StreamingHttpResponse: +def DownloadFile( + data, filename, content_type='application/text', inline=False +) -> StreamingHttpResponse: """Create a dynamic file for the user to download. Args: @@ -502,7 +508,6 @@ def extract_serial_numbers(input_string, expected_quantity: int, starting_value= return serials for group in groups: - # Calculate the "remaining" quantity of serial numbers remaining = expected_quantity - len(serials) @@ -549,8 +554,16 @@ def extract_serial_numbers(input_string, expected_quantity: int, starting_value= break if len(group_items) > remaining: - add_error(_(f"Group range {group} exceeds allowed quantity ({expected_quantity})")) - elif len(group_items) > 0 and group_items[0] == a and group_items[-1] == b: + add_error( + _( + f"Group range {group} exceeds allowed quantity ({expected_quantity})" + ) + ) + elif ( + len(group_items) > 0 + and group_items[0] == a + and group_items[-1] == b + ): # In this case, the range extraction looks like it has worked for item in group_items: add_serial(item) @@ -586,7 +599,11 @@ def extract_serial_numbers(input_string, expected_quantity: int, starting_value= value = items[0] # Keep incrementing up to the specified quantity - while value is not None and value not in sequence_items and counter < sequence_count: + while ( + value is not None + and value not in sequence_items + and counter < sequence_count + ): sequence_items.append(value) value = increment_serial_number(value) counter += 1 @@ -608,7 +625,11 @@ def extract_serial_numbers(input_string, expected_quantity: int, starting_value= raise ValidationError([_("No serial numbers found")]) if len(errors) == 0 and len(serials) != expected_quantity: - raise ValidationError([_(f"Number of unique serial numbers ({len(serials)}) must match quantity ({expected_quantity})")]) + raise ValidationError([ + _( + f"Number of unique serial numbers ({len(serials)}) must match quantity ({expected_quantity})" + ) + ]) return serials @@ -645,9 +666,7 @@ def validateFilterString(value, model=None): pair = group.split('=') if len(pair) != 2: - raise ValidationError( - f"Invalid group: {group}" - ) + raise ValidationError(f"Invalid group: {group}") k, v = pair @@ -655,9 +674,7 @@ def validateFilterString(value, model=None): v = v.strip() if not k or not v: - raise ValidationError( - f"Invalid group: {group}" - ) + raise ValidationError(f"Invalid group: {group}") results[k] = v @@ -666,9 +683,7 @@ def validateFilterString(value, model=None): try: model.objects.filter(**results) except FieldError as e: - raise ValidationError( - str(e), - ) + raise ValidationError(str(e)) return results @@ -706,7 +721,11 @@ def clean_decimal(number): # Number cannot be converted to Decimal (eg. a string containing letters) return Decimal(0) - return clean_number.quantize(Decimal(1)) if clean_number == clean_number.to_integral() else clean_number.normalize() + return ( + clean_number.quantize(Decimal(1)) + if clean_number == clean_number.to_integral() + else clean_number.normalize() + ) def strip_html_tags(value: str, raise_error=True, field_name=None): @@ -714,55 +733,45 @@ def strip_html_tags(value: str, raise_error=True, field_name=None): If raise_error is True, a ValidationError will be thrown if HTML tags are detected """ - cleaned = clean( - value, - strip=True, - tags=[], - attributes=[], - ) + cleaned = clean(value, strip=True, tags=[], attributes=[]) # Add escaped characters back in - replacements = { - '>': '>', - '<': '<', - '&': '&', - } + replacements = {'>': '>', '<': '<', '&': '&'} for o, r in replacements.items(): cleaned = cleaned.replace(o, r) # If the length changed, it means that HTML tags were removed! if len(cleaned) != len(value) and raise_error: - field = field_name or 'non_field_errors' - raise ValidationError({ - field: [_("Remove HTML tags from this value")] - }) + raise ValidationError({field: [_("Remove HTML tags from this value")]}) return cleaned -def remove_non_printable_characters(value: str, remove_newline=True, remove_ascii=True, remove_unicode=True): +def remove_non_printable_characters( + value: str, remove_newline=True, remove_ascii=True, remove_unicode=True +): """Remove non-printable / control characters from the provided string""" cleaned = value if remove_ascii: # Remove ASCII control characters # Note that we do not sub out 0x0A (\n) here, it is done separately below - cleaned = regex.sub(u'[\x00-\x09]+', '', cleaned) - cleaned = regex.sub(u'[\x0b-\x1F\x7F]+', '', cleaned) + cleaned = regex.sub('[\x00-\x09]+', '', cleaned) + cleaned = regex.sub('[\x0b-\x1f\x7f]+', '', cleaned) if remove_newline: - cleaned = regex.sub(u'[\x0a]+', '', cleaned) + cleaned = regex.sub('[\x0a]+', '', cleaned) if remove_unicode: # Remove Unicode control characters if remove_newline: - cleaned = regex.sub(u'[^\P{C}]+', '', cleaned) + cleaned = regex.sub('[^\P{C}]+', '', cleaned) else: # Use 'negative-lookahead' to exclude newline character - cleaned = regex.sub(u'(?![\x0A])[^\P{C}]+', '', cleaned) + cleaned = regex.sub('(?![\x0a])[^\P{C}]+', '', cleaned) return cleaned @@ -783,7 +792,9 @@ def hash_barcode(barcode_data): return str(hash.hexdigest()) -def get_objectreference(obj, type_ref: str = 'content_type', object_ref: str = 'object_id'): +def get_objectreference( + obj, type_ref: str = 'content_type', object_ref: str = 'object_id' +): """Lookup method for the GenericForeignKey fields. Attributes: @@ -821,11 +832,7 @@ def get_objectreference(obj, type_ref: str = 'content_type', object_ref: str = ' ret = {} if url_fnc: ret['link'] = url_fnc() - return { - 'name': str(item), - 'model': str(model_cls._meta.verbose_name), - **ret - } + return {'name': str(item), 'model': str(model_cls._meta.verbose_name), **ret} def inheritors(cls): diff --git a/InvenTree/InvenTree/helpers_model.py b/InvenTree/InvenTree/helpers_model.py index 5dc37cf723..09e4481ca9 100644 --- a/InvenTree/InvenTree/helpers_model.py +++ b/InvenTree/InvenTree/helpers_model.py @@ -19,8 +19,11 @@ import common.models import InvenTree import InvenTree.helpers_model import InvenTree.version -from common.notifications import (InvenTreeNotificationBodies, - NotificationBody, trigger_notification) +from common.notifications import ( + InvenTreeNotificationBodies, + NotificationBody, + trigger_notification, +) from InvenTree.format import format_money logger = logging.getLogger('inventree') @@ -49,7 +52,9 @@ def construct_absolute_url(*arg, **kwargs): if not site_url: # Otherwise, try to use the InvenTree setting try: - site_url = common.models.InvenTreeSetting.get_setting('INVENTREE_BASE_URL', create=False, cache=False) + site_url = common.models.InvenTreeSetting.get_setting( + 'INVENTREE_BASE_URL', create=False, cache=False + ) except (ProgrammingError, OperationalError): pass @@ -100,10 +105,20 @@ def download_image_from_url(remote_url, timeout=2.5): validator(remote_url) # Calculate maximum allowable image size (in bytes) - max_size = int(common.models.InvenTreeSetting.get_setting('INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE')) * 1024 * 1024 + max_size = ( + int( + common.models.InvenTreeSetting.get_setting( + 'INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE' + ) + ) + * 1024 + * 1024 + ) # Add user specified user-agent to request (if specified) - user_agent = common.models.InvenTreeSetting.get_setting('INVENTREE_DOWNLOAD_FROM_URL_USER_AGENT') + user_agent = common.models.InvenTreeSetting.get_setting( + 'INVENTREE_DOWNLOAD_FROM_URL_USER_AGENT' + ) if user_agent: headers = {"User-Agent": user_agent} else: @@ -124,12 +139,16 @@ def download_image_from_url(remote_url, timeout=2.5): except requests.exceptions.Timeout as exc: raise exc except requests.exceptions.HTTPError: - raise requests.exceptions.HTTPError(_("Server responded with invalid status code") + f": {response.status_code}") + raise requests.exceptions.HTTPError( + _("Server responded with invalid status code") + f": {response.status_code}" + ) except Exception as exc: raise Exception(_("Exception occurred") + f": {str(exc)}") if response.status_code != 200: - raise Exception(_("Server responded with invalid status code") + f": {response.status_code}") + raise Exception( + _("Server responded with invalid status code") + f": {response.status_code}" + ) try: content_length = int(response.headers.get('Content-Length', 0)) @@ -167,7 +186,13 @@ def download_image_from_url(remote_url, timeout=2.5): return img -def render_currency(money, decimal_places=None, currency=None, min_decimal_places=None, max_decimal_places=None): +def render_currency( + money, + decimal_places=None, + currency=None, + min_decimal_places=None, + max_decimal_places=None, +): """Render a currency / Money object to a formatted string (e.g. for reports) Arguments: @@ -192,13 +217,19 @@ def render_currency(money, decimal_places=None, currency=None, min_decimal_place pass if decimal_places is None: - decimal_places = common.models.InvenTreeSetting.get_setting('PRICING_DECIMAL_PLACES', 6) + decimal_places = common.models.InvenTreeSetting.get_setting( + 'PRICING_DECIMAL_PLACES', 6 + ) if min_decimal_places is None: - min_decimal_places = common.models.InvenTreeSetting.get_setting('PRICING_DECIMAL_PLACES_MIN', 0) + min_decimal_places = common.models.InvenTreeSetting.get_setting( + 'PRICING_DECIMAL_PLACES_MIN', 0 + ) if max_decimal_places is None: - max_decimal_places = common.models.InvenTreeSetting.get_setting('PRICING_DECIMAL_PLACES', 6) + max_decimal_places = common.models.InvenTreeSetting.get_setting( + 'PRICING_DECIMAL_PLACES', 6 + ) value = Decimal(str(money.amount)).normalize() value = str(value) @@ -229,7 +260,9 @@ def getModelsWithMixin(mixin_class) -> list: from django.contrib.contenttypes.models import ContentType try: - db_models = [x.model_class() for x in ContentType.objects.all() if x is not None] + db_models = [ + x.model_class() for x in ContentType.objects.all() if x is not None + ] except (OperationalError, ProgrammingError): # Database is likely not yet ready db_models = [] @@ -237,7 +270,12 @@ def getModelsWithMixin(mixin_class) -> list: return [x for x in db_models if x is not None and issubclass(x, mixin_class)] -def notify_responsible(instance, sender, content: NotificationBody = InvenTreeNotificationBodies.NewOrder, exclude=None): +def notify_responsible( + instance, + sender, + content: NotificationBody = InvenTreeNotificationBodies.NewOrder, + exclude=None, +): """Notify all responsible parties of a change in an instance. Parses the supplied content with the provided instance and sender and sends a notification to all responsible users, @@ -249,10 +287,18 @@ def notify_responsible(instance, sender, content: NotificationBody = InvenTreeNo content (NotificationBody, optional): _description_. Defaults to InvenTreeNotificationBodies.NewOrder. exclude (User, optional): User instance that should be excluded. Defaults to None. """ - notify_users([instance.responsible], instance, sender, content=content, exclude=exclude) + notify_users( + [instance.responsible], instance, sender, content=content, exclude=exclude + ) -def notify_users(users, instance, sender, content: NotificationBody = InvenTreeNotificationBodies.NewOrder, exclude=None): +def notify_users( + users, + instance, + sender, + content: NotificationBody = InvenTreeNotificationBodies.NewOrder, + exclude=None, +): """Notify all passed users or groups. Parses the supplied content with the provided instance and sender and sends a notification to all users, @@ -278,10 +324,10 @@ def notify_users(users, instance, sender, content: NotificationBody = InvenTreeN 'instance': instance, 'name': content.name.format(**content_context), 'message': content.message.format(**content_context), - 'link': InvenTree.helpers_model.construct_absolute_url(instance.get_absolute_url()), - 'template': { - 'subject': content.name.format(**content_context), - } + 'link': InvenTree.helpers_model.construct_absolute_url( + instance.get_absolute_url() + ), + 'template': {'subject': content.name.format(**content_context)}, } if content.template: diff --git a/InvenTree/InvenTree/magic_login.py b/InvenTree/InvenTree/magic_login.py index b48c3f3547..2456da92b6 100644 --- a/InvenTree/InvenTree/magic_login.py +++ b/InvenTree/InvenTree/magic_login.py @@ -1,4 +1,5 @@ """Functions for magic login.""" + from django.conf import settings from django.contrib.auth.models import User from django.contrib.sites.models import Site @@ -17,12 +18,10 @@ def send_simple_login_email(user, link): """Send an email with the login link to this user.""" site = Site.objects.get_current() - context = { - "username": user.username, - "site_name": site.name, - "link": link, - } - email_plaintext_message = render_to_string("InvenTree/user_simple_login.txt", context) + context = {"username": user.username, "site_name": site.name, "link": link} + email_plaintext_message = render_to_string( + "InvenTree/user_simple_login.txt", context + ) send_mail( _(f"[{site.name}] Log in to the app"), diff --git a/InvenTree/InvenTree/management/commands/prerender.py b/InvenTree/InvenTree/management/commands/prerender.py index 5bba15109b..09c0c3b565 100644 --- a/InvenTree/InvenTree/management/commands/prerender.py +++ b/InvenTree/InvenTree/management/commands/prerender.py @@ -13,7 +13,6 @@ from django.utils.translation import override as lang_over def render_file(file_name, source, target, locales, ctx): """Renders a file into all provided locales.""" for locale in locales: - # Enforce lower-case for locale names locale = locale.lower() locale = locale.replace('_', '-') @@ -50,15 +49,19 @@ class Command(BaseCommand): # render! request = HttpRequest() ctx = {} - processors = tuple(import_string(path) for path in settings.STATFILES_I18_PROCESSORS) + processors = tuple( + import_string(path) for path in settings.STATFILES_I18_PROCESSORS + ) for processor in processors: ctx.update(processor(request)) - for file in os.listdir(SOURCE_DIR, ): + for file in os.listdir(SOURCE_DIR): path = os.path.join(SOURCE_DIR, file) if os.path.exists(path) and os.path.isfile(path): print(f"render {file}") render_file(file, SOURCE_DIR, TARGET_DIR, locales, ctx) else: - raise NotImplementedError('Using multi-level directories is not implemented at this point') # TODO multilevel dir if needed + raise NotImplementedError( + 'Using multi-level directories is not implemented at this point' + ) # TODO multilevel dir if needed print(f"rendered all files in {SOURCE_DIR}") diff --git a/InvenTree/InvenTree/management/commands/rebuild_models.py b/InvenTree/InvenTree/management/commands/rebuild_models.py index 87aa378f0b..b735c43735 100644 --- a/InvenTree/InvenTree/management/commands/rebuild_models.py +++ b/InvenTree/InvenTree/management/commands/rebuild_models.py @@ -16,6 +16,7 @@ class Command(BaseCommand): print("Rebuilding Part objects") from part.models import Part + Part.objects.rebuild() except Exception: print("Error rebuilding Part objects") @@ -25,6 +26,7 @@ class Command(BaseCommand): print("Rebuilding PartCategory objects") from part.models import PartCategory + PartCategory.objects.rebuild() except Exception: print("Error rebuilding PartCategory objects") @@ -34,6 +36,7 @@ class Command(BaseCommand): print("Rebuilding StockItem objects") from stock.models import StockItem + StockItem.objects.rebuild() except Exception: print("Error rebuilding StockItem objects") @@ -43,6 +46,7 @@ class Command(BaseCommand): print("Rebuilding StockLocation objects") from stock.models import StockLocation + StockLocation.objects.rebuild() except Exception: print("Error rebuilding StockLocation objects") @@ -52,6 +56,7 @@ class Command(BaseCommand): print("Rebuilding Build objects") from build.models import Build + Build.objects.rebuild() except Exception: print("Error rebuilding Build objects") diff --git a/InvenTree/InvenTree/management/commands/remove_mfa.py b/InvenTree/InvenTree/management/commands/remove_mfa.py index cd4ef8c88b..4cb4f21659 100644 --- a/InvenTree/InvenTree/management/commands/remove_mfa.py +++ b/InvenTree/InvenTree/management/commands/remove_mfa.py @@ -18,7 +18,12 @@ class Command(BaseCommand): if not mail: raise KeyError('A mail is required') user = get_user_model() - mfa_user = [*set(user.objects.filter(email=mail) | user.objects.filter(emailaddress__email=mail))] + mfa_user = [ + *set( + user.objects.filter(email=mail) + | user.objects.filter(emailaddress__email=mail) + ) + ] if len(mfa_user) == 0: print('No user with this mail associated') diff --git a/InvenTree/InvenTree/management/commands/wait_for_db.py b/InvenTree/InvenTree/management/commands/wait_for_db.py index 3bcd2f5ffd..16223d4f77 100644 --- a/InvenTree/InvenTree/management/commands/wait_for_db.py +++ b/InvenTree/InvenTree/management/commands/wait_for_db.py @@ -17,7 +17,6 @@ class Command(BaseCommand): connected = False while not connected: - time.sleep(2) try: diff --git a/InvenTree/InvenTree/metadata.py b/InvenTree/InvenTree/metadata.py index a526cf152e..3bde8f84e5 100644 --- a/InvenTree/InvenTree/metadata.py +++ b/InvenTree/InvenTree/metadata.py @@ -45,8 +45,9 @@ class InvenTreeMetadata(SimpleMetadata): context = {} if str2bool(request.query_params.get('context', False)): - - if hasattr(self, 'serializer') and hasattr(self.serializer, 'get_context_data'): + if hasattr(self, 'serializer') and hasattr( + self.serializer, 'get_context_data' + ): context = self.serializer.get_context_data() metadata['context'] = context @@ -91,7 +92,6 @@ class InvenTreeMetadata(SimpleMetadata): # Remove any HTTP methods that the user does not have permission for for method, permission in rolemap.items(): - result = check(user, table, permission) if method in actions and not result: @@ -123,10 +123,7 @@ class InvenTreeMetadata(SimpleMetadata): model_class = None # Attributes to copy extra attributes from the model to the field (if they don't exist) - extra_attributes = [ - 'help_text', - 'max_length', - ] + extra_attributes = ['help_text', 'max_length'] try: model_class = serializer.Meta.model @@ -142,11 +139,8 @@ class InvenTreeMetadata(SimpleMetadata): # Iterate through simple fields for name, field in model_fields.fields.items(): - if name in serializer_info.keys(): - if field.has_default(): - default = field.default if callable(default): @@ -162,13 +156,11 @@ class InvenTreeMetadata(SimpleMetadata): for attr in extra_attributes: if attr not in serializer_info[name]: - if hasattr(field, attr): serializer_info[name][attr] = getattr(field, attr) # Iterate through relations for name, relation in model_fields.relations.items(): - if name not in serializer_info.keys(): # Skip relation not defined in serializer continue @@ -179,11 +171,17 @@ class InvenTreeMetadata(SimpleMetadata): # Extract and provide the "limit_choices_to" filters # This is used to automatically filter AJAX requests - serializer_info[name]['filters'] = relation.model_field.get_limit_choices_to() + serializer_info[name]['filters'] = ( + relation.model_field.get_limit_choices_to() + ) for attr in extra_attributes: - if attr not in serializer_info[name] and hasattr(relation.model_field, attr): - serializer_info[name][attr] = getattr(relation.model_field, attr) + if attr not in serializer_info[name] and hasattr( + relation.model_field, attr + ): + serializer_info[name][attr] = getattr( + relation.model_field, attr + ) if name in model_default_values: serializer_info[name]['default'] = model_default_values[name] @@ -220,11 +218,9 @@ class InvenTreeMetadata(SimpleMetadata): """If there is an instance associated with this API View, introspect that instance to find any specific API info.""" if hasattr(instance, 'api_instance_filters'): - instance_filters = instance.api_instance_filters() for field_name, field_filters in instance_filters.items(): - if field_name not in serializer_info.keys(): # The field might be missing, but is added later on # This function seems to get called multiple times? @@ -256,17 +252,20 @@ class InvenTreeMetadata(SimpleMetadata): # Force non-nullable fields to read as "required" # (even if there is a default value!) - if not field.allow_null and not (hasattr(field, 'allow_blank') and field.allow_blank): + if not field.allow_null and not ( + hasattr(field, 'allow_blank') and field.allow_blank + ): field_info['required'] = True # Introspect writable related fields if field_info['type'] == 'field' and not field_info['read_only']: - # If the field is a PrimaryKeyRelatedField, we can extract the model from the queryset if isinstance(field, serializers.PrimaryKeyRelatedField): model = field.queryset.model else: - logger.debug("Could not extract model for:", field_info.get('label'), '->', field) + logger.debug( + "Could not extract model for:", field_info.get('label'), '->', field + ) model = None if model: diff --git a/InvenTree/InvenTree/middleware.py b/InvenTree/InvenTree/middleware.py index 7fac82e13f..50a632c6ec 100644 --- a/InvenTree/InvenTree/middleware.py +++ b/InvenTree/InvenTree/middleware.py @@ -9,8 +9,7 @@ from django.http import HttpResponse from django.shortcuts import redirect from django.urls import Resolver404, include, re_path, resolve, reverse_lazy -from allauth_2fa.middleware import (AllauthTwoFactorMiddleware, - BaseRequire2FAMiddleware) +from allauth_2fa.middleware import AllauthTwoFactorMiddleware, BaseRequire2FAMiddleware from error_report.middleware import ExceptionProcessor from InvenTree.urls import frontendpatterns @@ -64,11 +63,20 @@ class AuthRequiredMiddleware(object): elif request.path_info.startswith('/accounts/'): authorized = True - elif request.path_info.startswith(f'/{settings.FRONTEND_URL_BASE}/') or request.path_info.startswith('/assets/') or request.path_info == f'/{settings.FRONTEND_URL_BASE}': + elif ( + request.path_info.startswith(f'/{settings.FRONTEND_URL_BASE}/') + or request.path_info.startswith('/assets/') + or request.path_info == f'/{settings.FRONTEND_URL_BASE}' + ): authorized = True - elif 'Authorization' in request.headers.keys() or 'authorization' in request.headers.keys(): - auth = request.headers.get('Authorization', request.headers.get('authorization')).strip() + elif ( + 'Authorization' in request.headers.keys() + or 'authorization' in request.headers.keys() + ): + auth = request.headers.get( + 'Authorization', request.headers.get('authorization') + ).strip() if auth.lower().startswith('token') and len(auth.split()) == 2: token_key = auth.split()[1] @@ -78,7 +86,6 @@ class AuthRequiredMiddleware(object): token = ApiToken.objects.get(key=token_key) if token.active and token.user: - # Provide the user information to the request request.user = token.user authorized = True @@ -99,17 +106,16 @@ class AuthRequiredMiddleware(object): ] # Do not redirect requests to any of these paths - paths_ignore = [ - '/api/', - '/js/', - '/media/', - '/static/', - ] + paths_ignore = ['/api/', '/js/', '/media/', '/static/'] - if path not in urls and not any(path.startswith(p) for p in paths_ignore): + 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(f'{reverse_lazy("account_login")}?next={request.path}') + return redirect( + f'{reverse_lazy("account_login")}?next={request.path}' + ) # Return a 401 (Unauthorized) response code for this request return HttpResponse('Unauthorized', status=401) @@ -123,6 +129,7 @@ url_matcher = re_path('', include(frontendpatterns)) class Check2FAMiddleware(BaseRequire2FAMiddleware): """Check if user is required to have MFA enabled.""" + def require_2fa(self, request): """Use setting to check if MFA should be enforced for frontend page.""" from common.models import InvenTreeSetting @@ -137,6 +144,7 @@ class Check2FAMiddleware(BaseRequire2FAMiddleware): class CustomAllauthTwoFactorMiddleware(AllauthTwoFactorMiddleware): """This function ensures only frontend code triggers the MFA auth cycle.""" + def process_request(self, request): """Check if requested url is forntend and enforce MFA check.""" try: @@ -148,6 +156,7 @@ class CustomAllauthTwoFactorMiddleware(AllauthTwoFactorMiddleware): class InvenTreeRemoteUserMiddleware(PersistentRemoteUserMiddleware): """Middleware to check if HTTP-header based auth is enabled and to set it up.""" + header = settings.REMOTE_LOGIN_HEADER def process_request(self, request): diff --git a/InvenTree/InvenTree/mixins.py b/InvenTree/InvenTree/mixins.py index 41eb8a5ec6..6981a6bb6f 100644 --- a/InvenTree/InvenTree/mixins.py +++ b/InvenTree/InvenTree/mixins.py @@ -62,7 +62,7 @@ class DiffMixin: return field_name in self.get_field_deltas() -class CleanMixin(): +class CleanMixin: """Model mixin class which cleans inputs using the Mozilla bleach tools.""" # Define a list of field names which will *not* be cleaned @@ -74,13 +74,17 @@ class CleanMixin(): serializer.is_valid(raise_exception=True) self.perform_create(serializer) headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + return Response( + serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) def update(self, request, *args, **kwargs): """Override to clean data before processing it.""" partial = kwargs.pop('partial', False) instance = self.get_object() - serializer = self.get_serializer(instance, data=self.clean_data(request.data), partial=partial) + serializer = self.get_serializer( + instance, data=self.clean_data(request.data), partial=partial + ) serializer.is_valid(raise_exception=True) self.perform_update(serializer) @@ -113,9 +117,7 @@ class CleanMixin(): field = model._meta.get_field(field) # The following field types allow newline characters - allow_newline = [ - InvenTreeNotesField, - ] + allow_newline = [InvenTreeNotesField] for field_type in allow_newline: if issubclass(type(field), field_type): @@ -127,7 +129,9 @@ class CleanMixin(): except FieldDoesNotExist: pass - cleaned = remove_non_printable_characters(cleaned, remove_newline=remove_newline) + cleaned = remove_non_printable_characters( + cleaned, remove_newline=remove_newline + ) return cleaned @@ -148,7 +152,6 @@ class CleanMixin(): clean_data = {} for k, v in data.items(): - if k in self.SAFE_FIELDS: ret = v elif isinstance(v, str): @@ -177,16 +180,19 @@ class CreateAPI(CleanMixin, generics.CreateAPIView): class RetrieveAPI(generics.RetrieveAPIView): """View for retrieve API.""" + pass class RetrieveUpdateAPI(CleanMixin, generics.RetrieveUpdateAPIView): """View for retrieve and update API.""" + pass class CustomDestroyModelMixin: """This mixin was created pass the kwargs from the API to the models.""" + def destroy(self, request, *args, **kwargs): """Custom destroy method to pass kwargs.""" instance = self.get_object() @@ -198,11 +204,14 @@ class CustomDestroyModelMixin: instance.delete(**kwargs) -class CustomRetrieveUpdateDestroyAPIView(mixins.RetrieveModelMixin, - mixins.UpdateModelMixin, - CustomDestroyModelMixin, - generics.GenericAPIView): +class CustomRetrieveUpdateDestroyAPIView( + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + CustomDestroyModelMixin, + generics.GenericAPIView, +): """This APIView was created pass the kwargs from the API to the models.""" + def get(self, request, *args, **kwargs): """Custom get method to pass kwargs.""" return self.retrieve(request, *args, **kwargs) diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py index a4b147ff08..ef6ef5b4dd 100644 --- a/InvenTree/InvenTree/models.py +++ b/InvenTree/InvenTree/models.py @@ -58,6 +58,7 @@ class MetadataMixin(models.Model): class Meta: """Meta for MetadataMixin.""" + abstract = True def save(self, *args, **kwargs): @@ -78,10 +79,13 @@ class MetadataMixin(models.Model): self.metadata = {} if type(self.metadata) is not dict: - raise ValidationError({'metadata': _('Metadata must be a python dict object')}) + raise ValidationError({ + 'metadata': _('Metadata must be a python dict object') + }) metadata = models.JSONField( - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Plugin Metadata'), help_text=_('JSON metadata field, for use by external plugins'), ) @@ -100,7 +104,9 @@ class MetadataMixin(models.Model): return self.metadata.get(key, backup_value) - def set_metadata(self, key: str, data, commit: bool = True, overwrite: bool = False): + def set_metadata( + self, key: str, data, commit: bool = True, overwrite: bool = False + ): """Save the provided metadata under the provided key. Args: @@ -137,7 +143,6 @@ class DataImportMixin(object): fields = cls.IMPORT_FIELDS for name, field in fields.items(): - # Attempt to extract base field information from the model base_field = None @@ -207,7 +212,10 @@ class ReferenceIndexingMixin(models.Model): # import at function level to prevent cyclic imports from common.models import InvenTreeSetting - return InvenTreeSetting.get_setting(cls.REFERENCE_PATTERN_SETTING, create=False).strip() + + return InvenTreeSetting.get_setting( + cls.REFERENCE_PATTERN_SETTING, create=False + ).strip() @classmethod def get_reference_context(cls): @@ -216,10 +224,7 @@ class ReferenceIndexingMixin(models.Model): - Returns a python dict object which contains the context data for formatting the reference string. - The default implementation provides some default context information """ - return { - 'ref': cls.get_next_reference(), - 'date': datetime.now(), - } + return {'ref': cls.get_next_reference(), 'date': datetime.now()} @classmethod def get_most_recent_item(cls): @@ -246,7 +251,9 @@ class ReferenceIndexingMixin(models.Model): reference = latest.reference.strip try: - reference = InvenTree.format.extract_named_group('ref', reference, cls.get_reference_pattern()) + reference = InvenTree.format.extract_named_group( + 'ref', reference, cls.get_reference_pattern() + ) except Exception: # If reference cannot be extracted using the pattern, try just the integer value reference = str(latest.reference_int) @@ -340,7 +347,9 @@ class ReferenceIndexingMixin(models.Model): return if not InvenTree.format.validate_string(value, pattern): - raise ValidationError(_("Reference must match required pattern") + ": " + pattern) + raise ValidationError( + _("Reference must match required pattern") + ": " + pattern + ) # Check that the reference field can be rebuild cls.rebuild_reference_field(value, validate=True) @@ -361,7 +370,9 @@ class ReferenceIndexingMixin(models.Model): """ try: # Extract named group based on provided pattern - reference = InvenTree.format.extract_named_group('ref', reference, cls.get_reference_pattern()) + reference = InvenTree.format.extract_named_group( + 'ref', reference, cls.get_reference_pattern() + ) except Exception: pass @@ -369,16 +380,14 @@ class ReferenceIndexingMixin(models.Model): if validate: if reference_int > models.BigIntegerField.MAX_BIGINT: - raise ValidationError({ - "reference": _("Reference number is too large") - }) + raise ValidationError({"reference": _("Reference number is too large")}) return reference_int reference_int = models.BigIntegerField(default=0) -def extract_int(reference, clip=0x7fffffff, allow_negative=False): +def extract_int(reference, clip=0x7FFFFFFF, allow_negative=False): """Extract an integer out of reference.""" # Default value if we cannot convert to an integer ref_int = 0 @@ -438,6 +447,7 @@ class InvenTreeAttachment(models.Model): class Meta: """Metaclass options. Abstract ensures no database table is created.""" + abstract = True def getSubdir(self): @@ -472,28 +482,40 @@ class InvenTreeAttachment(models.Model): return os.path.basename(self.attachment.name) return str(self.link) - attachment = models.FileField(upload_to=rename_attachment, verbose_name=_('Attachment'), - help_text=_('Select file to attach'), - blank=True, null=True - ) - - link = InvenTree.fields.InvenTreeURLField( - blank=True, null=True, - verbose_name=_('Link'), - help_text=_('Link to external URL') + attachment = models.FileField( + upload_to=rename_attachment, + verbose_name=_('Attachment'), + help_text=_('Select file to attach'), + blank=True, + null=True, ) - comment = models.CharField(blank=True, max_length=100, verbose_name=_('Comment'), help_text=_('File comment')) + link = InvenTree.fields.InvenTreeURLField( + blank=True, + null=True, + verbose_name=_('Link'), + help_text=_('Link to external URL'), + ) + + comment = models.CharField( + blank=True, + max_length=100, + verbose_name=_('Comment'), + help_text=_('File comment'), + ) user = models.ForeignKey( User, on_delete=models.SET_NULL, - blank=True, null=True, + blank=True, + null=True, verbose_name=_('User'), help_text=_('User'), ) - upload_date = models.DateField(auto_now_add=True, null=True, blank=True, verbose_name=_('upload date')) + upload_date = models.DateField( + auto_now_add=True, null=True, blank=True, verbose_name=_('upload date') + ) @property def basename(self): @@ -522,14 +544,36 @@ class InvenTreeAttachment(models.Model): # Check that there are no directory tricks going on... if new_file.parent != attachment_dir: - logger.error("Attempted to rename attachment outside valid directory: '%s'", new_file) + logger.error( + "Attempted to rename attachment outside valid directory: '%s'", new_file + ) raise ValidationError(_("Invalid attachment directory")) # Ignore further checks if the filename is not actually being renamed if new_file == old_file: return - forbidden = ["'", '"', "#", "@", "!", "&", "^", "<", ">", ":", ";", "/", "\\", "|", "?", "*", "%", "~", "`"] + forbidden = [ + "'", + '"', + "#", + "@", + "!", + "&", + "^", + "<", + ">", + ":", + ";", + "/", + "\\", + "|", + "?", + "*", + "%", + "~", + "`", + ] for c in forbidden: if c in fn: @@ -539,7 +583,9 @@ class InvenTreeAttachment(models.Model): raise ValidationError(_("Filename missing extension")) if not old_file.exists(): - logger.error("Trying to rename attachment '%s' which does not exist", old_file) + logger.error( + "Trying to rename attachment '%s' which does not exist", old_file + ) return if new_file.exists(): @@ -586,10 +632,12 @@ class InvenTreeTree(MPTTModel): class Meta: """Metaclass defines extra model properties.""" + abstract = True class MPTTMeta: """Set insert order.""" + order_insertion_by = ['name'] def delete(self, delete_children=False, delete_items=False): @@ -607,13 +655,19 @@ class InvenTreeTree(MPTTModel): self.refresh_from_db() except self.__class__.DoesNotExist: # If the object no longer exists, raise a ValidationError - raise ValidationError("Object %s of type %s no longer exists", str(self), str(self.__class__)) + raise ValidationError( + "Object %s of type %s no longer exists", str(self), str(self.__class__) + ) # Cache node ID values for lower nodes, before we delete this one - lower_nodes = list(self.get_descendants(include_self=False).values_list('pk', flat=True)) + lower_nodes = list( + self.get_descendants(include_self=False).values_list('pk', flat=True) + ) # 1. Update nodes and items under the current node - self.handle_tree_delete(delete_children=delete_children, delete_items=delete_items) + self.handle_tree_delete( + delete_children=delete_children, delete_items=delete_items + ) # 2. Delete *this* node super().delete() @@ -673,9 +727,7 @@ class InvenTreeTree(MPTTModel): # - Move all items at any lower level to the parent of this item # - Delete all descendant nodes elif delete_children and not delete_items: - self.get_items(cascade=True).update(**{ - self.ITEM_PARENT_KEY: self.parent - }) + self.get_items(cascade=True).update(**{self.ITEM_PARENT_KEY: self.parent}) self.delete_nodes(child_nodes) @@ -690,9 +742,7 @@ class InvenTreeTree(MPTTModel): # - Move all items directly associated with this node up one level # - Move any direct child nodes up one level elif not delete_children and not delete_items: - self.get_items(cascade=False).update(**{ - self.ITEM_PARENT_KEY: self.parent - }) + self.get_items(cascade=False).update(**{self.ITEM_PARENT_KEY: self.parent}) self.get_children().update(parent=self.parent) def delete_nodes(self, nodes): @@ -719,8 +769,7 @@ class InvenTreeTree(MPTTModel): super().validate_unique(exclude) results = self.__class__.objects.filter( - name=self.name, - parent=self.parent + name=self.name, parent=self.parent ).exclude(pk=self.pk) if results.exists(): @@ -730,17 +779,11 @@ class InvenTreeTree(MPTTModel): def api_instance_filters(self): """Instance filters for InvenTreeTree models.""" - return { - 'parent': { - 'exclude_tree': self.pk, - } - } + return {'parent': {'exclude_tree': self.pk}} def construct_pathstring(self): """Construct the pathstring for this tree node""" - return InvenTree.helpers.constructPathString( - [item.name for item in self.path] - ) + return InvenTree.helpers.constructPathString([item.name for item in self.path]) def save(self, *args, **kwargs): """Custom save method for InvenTreeTree abstract model""" @@ -748,15 +791,12 @@ class InvenTreeTree(MPTTModel): super().save(*args, **kwargs) except InvalidMove: # Provide better error for parent selection - raise ValidationError({ - 'parent': _("Invalid choice"), - }) + raise ValidationError({'parent': _("Invalid choice")}) # Re-calculate the 'pathstring' field pathstring = self.construct_pathstring() if pathstring != self.pathstring: - if 'force_insert' in kwargs: del kwargs['force_insert'] @@ -781,33 +821,29 @@ class InvenTreeTree(MPTTModel): self.__class__.objects.bulk_update(nodes_to_update, ['pathstring']) name = models.CharField( - blank=False, - max_length=100, - verbose_name=_("Name"), - help_text=_("Name"), + blank=False, max_length=100, verbose_name=_("Name"), help_text=_("Name") ) description = models.CharField( blank=True, max_length=250, verbose_name=_("Description"), - help_text=_("Description (optional)") + help_text=_("Description (optional)"), ) # When a category is deleted, graft the children onto its parent - parent = TreeForeignKey('self', - on_delete=models.DO_NOTHING, - blank=True, - null=True, - verbose_name=_("parent"), - related_name='children') + parent = TreeForeignKey( + 'self', + on_delete=models.DO_NOTHING, + blank=True, + null=True, + verbose_name=_("parent"), + related_name='children', + ) # The 'pathstring' field is calculated each time the model is saved pathstring = models.CharField( - blank=True, - max_length=250, - verbose_name=_('Path'), - help_text=_('Path') + blank=True, max_length=250, verbose_name=_('Path'), help_text=_('Path') ) def get_items(self, cascade=False): @@ -889,12 +925,7 @@ class InvenTreeTree(MPTTModel): name: , } """ - return [ - { - 'pk': item.pk, - 'name': item.name - } for item in self.path - ] + return [{'pk': item.pk, 'name': item.name} for item in self.path] def __str__(self): """String representation of a category is the full path to that category.""" @@ -914,11 +945,11 @@ class InvenTreeNotesMixin(models.Model): Note: abstract must be true, as this is only a mixin, not a separate table """ + abstract = True notes = InvenTree.fields.InvenTreeNotesField( - verbose_name=_('Notes'), - help_text=_('Markdown notes (optional)'), + verbose_name=_('Notes'), help_text=_('Markdown notes (optional)') ) @@ -941,18 +972,21 @@ class InvenTreeBarcodeMixin(models.Model): Note: abstract must be true, as this is only a mixin, not a separate table """ + abstract = True barcode_data = models.CharField( - blank=True, max_length=500, + blank=True, + max_length=500, verbose_name=_('Barcode Data'), help_text=_('Third party barcode data'), ) barcode_hash = models.CharField( - blank=True, max_length=128, + blank=True, + max_length=128, verbose_name=_('Barcode Hash'), - help_text=_('Unique hash of barcode data') + help_text=_('Unique hash of barcode data'), ) @classmethod @@ -964,17 +998,13 @@ class InvenTreeBarcodeMixin(models.Model): def format_barcode(self, **kwargs): """Return a JSON string for formatting a QR code for this model instance.""" return InvenTree.helpers.MakeBarcode( - self.__class__.barcode_model_type(), - self.pk, - **kwargs + self.__class__.barcode_model_type(), self.pk, **kwargs ) def format_matched_response(self): """Format a standard response for a matched barcode.""" - data = { - 'pk': self.pk, - } + data = {'pk': self.pk} if hasattr(self, 'get_api_url'): api_url = self.get_api_url() @@ -995,7 +1025,9 @@ class InvenTreeBarcodeMixin(models.Model): """Check if a model instance exists with the specified third-party barcode hash.""" return cls.objects.filter(barcode_hash=barcode_hash).first() - def assign_barcode(self, barcode_hash=None, barcode_data=None, raise_error=True, save=True): + def assign_barcode( + self, barcode_hash=None, barcode_data=None, raise_error=True, save=True + ): """Assign an external (third-party) barcode to this object.""" # Must provide either barcode_hash or barcode_data if barcode_hash is None and barcode_data is None: @@ -1044,20 +1076,24 @@ def after_error_logged(sender, instance: Error, created: bool, **kwargs): users = get_user_model().objects.filter(is_staff=True) link = InvenTree.helpers_model.construct_absolute_url( - reverse('admin:error_report_error_change', kwargs={'object_id': instance.pk}) + reverse( + 'admin:error_report_error_change', kwargs={'object_id': instance.pk} + ) ) context = { 'error': instance, 'name': _('Server Error'), 'message': _('An error has been logged by the server.'), - 'link': link + 'link': link, } target_users = [] for user in users: - if common.models.InvenTreeUserSetting.get_setting('NOTIFICATION_ERROR_REPORT', True, user=user): + if common.models.InvenTreeUserSetting.get_setting( + 'NOTIFICATION_ERROR_REPORT', True, user=user + ): target_users.append(user) if len(target_users) > 0: @@ -1066,7 +1102,7 @@ def after_error_logged(sender, instance: Error, created: bool, **kwargs): 'inventree.error_log', context=context, targets=target_users, - delivery_methods={common.notifications.UIMessageNotification, }, + delivery_methods={common.notifications.UIMessageNotification}, ) except Exception as exc: diff --git a/InvenTree/InvenTree/permissions.py b/InvenTree/InvenTree/permissions.py index befbcab4c3..6aae0d3c23 100644 --- a/InvenTree/InvenTree/permissions.py +++ b/InvenTree/InvenTree/permissions.py @@ -100,12 +100,18 @@ class IsStaffOrReadOnly(permissions.IsAdminUser): def has_permission(self, request, view): """Check if the user is a superuser.""" - return bool(request.user and request.user.is_staff or request.method in permissions.SAFE_METHODS) + return bool( + request.user + and request.user.is_staff + or request.method in permissions.SAFE_METHODS + ) def auth_exempt(view_func): """Mark a view function as being exempt from auth requirements.""" + def wrapped_view(*args, **kwargs): return view_func(*args, **kwargs) + wrapped_view.auth_exempt = True return wraps(view_func)(wrapped_view) diff --git a/InvenTree/InvenTree/ready.py b/InvenTree/InvenTree/ready.py index 31ce85fba6..f6217c15e4 100644 --- a/InvenTree/InvenTree/ready.py +++ b/InvenTree/InvenTree/ready.py @@ -31,7 +31,9 @@ def isInMainThread(): return True -def canAppAccessDatabase(allow_test: bool = False, allow_plugins: bool = False, allow_shell: bool = False): +def canAppAccessDatabase( + allow_test: bool = False, allow_plugins: bool = False, allow_shell: bool = False +): """Returns True if the apps.py file can access database records. There are some circumstances where we don't want the ready function in apps.py diff --git a/InvenTree/InvenTree/sanitizer.py b/InvenTree/InvenTree/sanitizer.py index 0565c1b7f2..0272b50f94 100644 --- a/InvenTree/InvenTree/sanitizer.py +++ b/InvenTree/InvenTree/sanitizer.py @@ -1,49 +1,196 @@ """Functions to sanitize user input files.""" + from bleach import clean from bleach.css_sanitizer import CSSSanitizer ALLOWED_ELEMENTS_SVG = [ - 'a', 'animate', 'animateColor', 'animateMotion', - 'animateTransform', 'circle', 'defs', 'desc', 'ellipse', 'font-face', - 'font-face-name', 'font-face-src', 'g', 'glyph', 'hkern', - 'linearGradient', 'line', 'marker', 'metadata', 'missing-glyph', - 'mpath', 'path', 'polygon', 'polyline', 'radialGradient', 'rect', - 'set', 'stop', 'svg', 'switch', 'text', 'title', 'tspan', 'use' + 'a', + 'animate', + 'animateColor', + 'animateMotion', + 'animateTransform', + 'circle', + 'defs', + 'desc', + 'ellipse', + 'font-face', + 'font-face-name', + 'font-face-src', + 'g', + 'glyph', + 'hkern', + 'linearGradient', + 'line', + 'marker', + 'metadata', + 'missing-glyph', + 'mpath', + 'path', + 'polygon', + 'polyline', + 'radialGradient', + 'rect', + 'set', + 'stop', + 'svg', + 'switch', + 'text', + 'title', + 'tspan', + 'use', ] ALLOWED_ATTRIBUTES_SVG = [ - 'accent-height', 'accumulate', 'additive', 'alphabetic', - 'arabic-form', 'ascent', 'attributeName', 'attributeType', - 'baseProfile', 'bbox', 'begin', 'by', 'calcMode', 'cap-height', - 'class', 'color', 'color-rendering', 'content', 'cx', 'cy', 'd', 'dx', - 'dy', 'descent', 'display', 'dur', 'end', 'fill', 'fill-opacity', - 'fill-rule', 'font-family', 'font-size', 'font-stretch', 'font-style', - 'font-variant', 'font-weight', 'from', 'fx', 'fy', 'g1', 'g2', - 'glyph-name', 'gradientUnits', 'hanging', 'height', 'horiz-adv-x', - 'horiz-origin-x', 'id', 'ideographic', 'k', 'keyPoints', - 'keySplines', 'keyTimes', 'lang', 'marker-end', 'marker-mid', - 'marker-start', 'markerHeight', 'markerUnits', 'markerWidth', - 'mathematical', 'max', 'min', 'name', 'offset', 'opacity', 'orient', - 'origin', 'overline-position', 'overline-thickness', 'panose-1', - 'path', 'pathLength', 'points', 'preserveAspectRatio', 'r', 'refX', - 'refY', 'repeatCount', 'repeatDur', 'requiredExtensions', - 'requiredFeatures', 'restart', 'rotate', 'rx', 'ry', 'slope', - 'stemh', 'stemv', 'stop-color', 'stop-opacity', - 'strikethrough-position', 'strikethrough-thickness', 'stroke', - 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', - 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', - 'stroke-width', 'systemLanguage', 'target', 'text-anchor', 'to', - 'transform', 'type', 'u1', 'u2', 'underline-position', - 'underline-thickness', 'unicode', 'unicode-range', 'units-per-em', - 'values', 'version', 'viewBox', 'visibility', 'width', 'widths', 'x', - 'x-height', 'x1', 'x2', 'xlink:actuate', 'xlink:arcrole', - 'xlink:href', 'xlink:role', 'xlink:show', 'xlink:title', - 'xlink:type', 'xml:base', 'xml:lang', 'xml:space', 'xmlns', - 'xmlns:xlink', 'y', 'y1', 'y2', 'zoomAndPan', 'style' + 'accent-height', + 'accumulate', + 'additive', + 'alphabetic', + 'arabic-form', + 'ascent', + 'attributeName', + 'attributeType', + 'baseProfile', + 'bbox', + 'begin', + 'by', + 'calcMode', + 'cap-height', + 'class', + 'color', + 'color-rendering', + 'content', + 'cx', + 'cy', + 'd', + 'dx', + 'dy', + 'descent', + 'display', + 'dur', + 'end', + 'fill', + 'fill-opacity', + 'fill-rule', + 'font-family', + 'font-size', + 'font-stretch', + 'font-style', + 'font-variant', + 'font-weight', + 'from', + 'fx', + 'fy', + 'g1', + 'g2', + 'glyph-name', + 'gradientUnits', + 'hanging', + 'height', + 'horiz-adv-x', + 'horiz-origin-x', + 'id', + 'ideographic', + 'k', + 'keyPoints', + 'keySplines', + 'keyTimes', + 'lang', + 'marker-end', + 'marker-mid', + 'marker-start', + 'markerHeight', + 'markerUnits', + 'markerWidth', + 'mathematical', + 'max', + 'min', + 'name', + 'offset', + 'opacity', + 'orient', + 'origin', + 'overline-position', + 'overline-thickness', + 'panose-1', + 'path', + 'pathLength', + 'points', + 'preserveAspectRatio', + 'r', + 'refX', + 'refY', + 'repeatCount', + 'repeatDur', + 'requiredExtensions', + 'requiredFeatures', + 'restart', + 'rotate', + 'rx', + 'ry', + 'slope', + 'stemh', + 'stemv', + 'stop-color', + 'stop-opacity', + 'strikethrough-position', + 'strikethrough-thickness', + 'stroke', + 'stroke-dasharray', + 'stroke-dashoffset', + 'stroke-linecap', + 'stroke-linejoin', + 'stroke-miterlimit', + 'stroke-opacity', + 'stroke-width', + 'systemLanguage', + 'target', + 'text-anchor', + 'to', + 'transform', + 'type', + 'u1', + 'u2', + 'underline-position', + 'underline-thickness', + 'unicode', + 'unicode-range', + 'units-per-em', + 'values', + 'version', + 'viewBox', + 'visibility', + 'width', + 'widths', + 'x', + 'x-height', + 'x1', + 'x2', + 'xlink:actuate', + 'xlink:arcrole', + 'xlink:href', + 'xlink:role', + 'xlink:show', + 'xlink:title', + 'xlink:type', + 'xml:base', + 'xml:lang', + 'xml:space', + 'xmlns', + 'xmlns:xlink', + 'y', + 'y1', + 'y2', + 'zoomAndPan', + 'style', ] -def sanitize_svg(file_data, strip: bool = True, elements: str = ALLOWED_ELEMENTS_SVG, attributes: str = ALLOWED_ATTRIBUTES_SVG) -> str: +def sanitize_svg( + file_data, + strip: bool = True, + elements: str = ALLOWED_ELEMENTS_SVG, + attributes: str = ALLOWED_ATTRIBUTES_SVG, +) -> str: """Sanitize a SVG file. Args: @@ -65,7 +212,7 @@ def sanitize_svg(file_data, strip: bool = True, elements: str = ALLOWED_ELEMENTS attributes=attributes, strip=strip, strip_comments=strip, - css_sanitizer=CSSSanitizer() + css_sanitizer=CSSSanitizer(), ) return cleaned diff --git a/InvenTree/InvenTree/sentry.py b/InvenTree/InvenTree/sentry.py index b1ead7a5cb..8dc8c9305e 100644 --- a/InvenTree/InvenTree/sentry.py +++ b/InvenTree/InvenTree/sentry.py @@ -46,7 +46,9 @@ def init_sentry(dsn, sample_rate, tags): send_default_pii=True, ignore_errors=sentry_ignore_errors(), release=InvenTree.version.INVENTREE_SW_VERSION, - environment='development' if InvenTree.version.isInvenTreeDevelopmentVersion() else 'production' + environment='development' + if InvenTree.version.isInvenTreeDevelopmentVersion() + else 'production', ) for key, val in tags.items(): @@ -62,7 +64,6 @@ def init_sentry(dsn, sample_rate, tags): def report_exception(exc): """Report an exception to sentry.io""" if settings.SENTRY_ENABLED and settings.SENTRY_DSN: - if not any(isinstance(exc, e) for e in sentry_ignore_errors()): logger.info("Reporting exception to sentry.io: %s", exc) diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index d0304bb313..c57b78e98d 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -57,13 +57,18 @@ class InvenTreeMoneySerializer(MoneyField): amount = Decimal(amount) amount = round(amount, self.decimal_places) except Exception: - raise ValidationError({ - self.field_name: [_("Must be a valid number")], - }) + raise ValidationError({self.field_name: [_("Must be a valid number")]}) - currency = data.get(get_currency_field_name(self.field_name), self.default_currency) + currency = data.get( + get_currency_field_name(self.field_name), self.default_currency + ) - if currency and amount is not None and not isinstance(amount, MONEY_CLASSES) and amount is not empty: + if ( + currency + and amount is not None + and not isinstance(amount, MONEY_CLASSES) + and amount is not empty + ): return Money(amount, currency) return amount @@ -76,7 +81,9 @@ class InvenTreeCurrencySerializer(serializers.ChoiceField): """Initialize the currency serializer""" choices = currency_code_mappings() - allow_blank = kwargs.get('allow_blank', False) or kwargs.get('allow_null', False) + allow_blank = kwargs.get('allow_blank', False) or kwargs.get( + 'allow_null', False + ) if allow_blank: choices = [('', '---------')] + choices @@ -97,6 +104,7 @@ class InvenTreeCurrencySerializer(serializers.ChoiceField): class DependentField(serializers.Field): """A dependent field can be used to dynamically return child fields based on the value of other fields.""" + child = None def __init__(self, *args, depends_on, field_serializer, **kwargs): @@ -138,6 +146,7 @@ class DependentField(serializers.Field): # only do for composite fields and stop right before the current field if hasattr(node, "child") and node is not self and isinstance(data, dict): data = data.get(node.field_name, None) + visit_parent(self) # ensure that data is a dictionary and that a parent exists @@ -152,7 +161,9 @@ class DependentField(serializers.Field): # partially validate the data for options requests that set raise_exception while calling .get_child(...) if raise_exception: validation_data = {k: v for k, v in data.items() if k in self.depends_on} - serializer = self.parent.__class__(context=self.context, data=validation_data, partial=True) + serializer = self.parent.__class__( + context=self.context, data=validation_data, partial=True + ) serializer.is_valid(raise_exception=raise_exception) # try to get the field serializer @@ -196,7 +207,6 @@ class InvenTreeModelSerializer(serializers.ModelSerializer): """Custom __init__ routine to ensure that *default* values (as specified in the ORM) are used by the DRF serializers, *if* the values are not provided by the user.""" # If instance is None, we are creating a new instance if instance is None and data is not empty: - if data is None: data = OrderedDict() else: @@ -211,7 +221,6 @@ class InvenTreeModelSerializer(serializers.ModelSerializer): fields = model_meta.get_field_info(ModelClass) for field_name, field in fields.fields.items(): - """ Update the field IF (and ONLY IF): @@ -219,7 +228,6 @@ class InvenTreeModelSerializer(serializers.ModelSerializer): - The field does not already have a value set """ if field.has_default() and field_name not in data: - value = field.default # Account for callable functions @@ -247,9 +255,7 @@ class InvenTreeModelSerializer(serializers.ModelSerializer): fields = model_meta.get_field_info(ModelClass) for field_name, field in fields.fields.items(): - if field.has_default() and field_name not in initials: - value = field.default # Account for callable functions @@ -337,7 +343,6 @@ class InvenTreeModelSerializer(serializers.ModelSerializer): try: instance.full_clean() except (ValidationError, DjangoValidationError) as exc: - data = exc.message_dict # Change '__all__' key (django style) to 'non_field_errors' (DRF style) @@ -369,6 +374,7 @@ class InvenTreeTaggitSerializer(TaggitSerializer): class InvenTreeTagModelSerializer(InvenTreeTaggitSerializer, InvenTreeModelSerializer): """Combination of InvenTreeTaggitSerializer and InvenTreeModelSerializer.""" + pass @@ -377,38 +383,31 @@ class UserSerializer(InvenTreeModelSerializer): class Meta: """Metaclass defines serializer fields.""" - model = User - fields = [ - 'pk', - 'username', - 'first_name', - 'last_name', - 'email', - ] - read_only_fields = [ - 'username', - ] + model = User + fields = ['pk', 'username', 'first_name', 'last_name', 'email'] + + read_only_fields = ['username'] class ExendedUserSerializer(UserSerializer): """Serializer for a User with a bit more info.""" + from users.serializers import GroupSerializer groups = GroupSerializer(read_only=True, many=True) class Meta(UserSerializer.Meta): """Metaclass defines serializer fields.""" + fields = UserSerializer.Meta.fields + [ 'groups', 'is_staff', 'is_superuser', - 'is_active' + 'is_active', ] - read_only_fields = UserSerializer.Meta.read_only_fields + [ - 'groups', - ] + read_only_fields = UserSerializer.Meta.read_only_fields + ['groups'] def validate(self, attrs): """Expanded validation for changing user role.""" @@ -424,12 +423,15 @@ class ExendedUserSerializer(UserSerializer): # Staff can change any role except is_superuser pass else: - raise PermissionDenied(_("You do not have permission to change this user role.")) + raise PermissionDenied( + _("You do not have permission to change this user role.") + ) return super().validate(attrs) class UserCreateSerializer(ExendedUserSerializer): """Serializer for creating a new User.""" + def validate(self, attrs): """Expanded valiadation for auth.""" # Check that the user trying to create a new user is a superuser @@ -452,7 +454,9 @@ class UserCreateSerializer(ExendedUserSerializer): domain = current_site.domain instance.email_user( subject=_(f"Welcome to {current_site.name}"), - message=_(f"Your account has been created.\n\nPlease use the password reset function to get access (at https://{domain})."), + message=_( + f"Your account has been created.\n\nPlease use the password reset function to get access (at https://{domain})." + ), ) return instance @@ -509,17 +513,11 @@ class InvenTreeAttachmentSerializer(InvenTreeModelSerializer): user_detail = UserSerializer(source='user', read_only=True, many=False) - attachment = InvenTreeAttachmentSerializerField( - required=False, - allow_null=False, - ) + attachment = InvenTreeAttachmentSerializerField(required=False, allow_null=False) # The 'filename' field must be present in the serializer filename = serializers.CharField( - label=_('Filename'), - required=False, - source='basename', - allow_blank=False, + label=_('Filename'), required=False, source='basename', allow_blank=False ) upload_date = serializers.DateField(read_only=True) @@ -570,9 +568,7 @@ class DataFileUploadSerializer(serializers.Serializer): class Meta: """Metaclass options.""" - fields = [ - 'data_file', - ] + fields = ['data_file'] data_file = serializers.FileField( label=_("Data File"), @@ -590,11 +586,7 @@ class DataFileUploadSerializer(serializers.Serializer): # Remove the leading . from the extension ext = ext[1:] - accepted_file_types = [ - 'xls', 'xlsx', - 'csv', 'tsv', - 'xml', - ] + accepted_file_types = ['xls', 'xlsx', 'csv', 'tsv', 'xml'] if ext not in accepted_file_types: raise serializers.ValidationError(_("Unsupported file type")) @@ -721,24 +713,14 @@ class DataFileExtractSerializer(serializers.Serializer): class Meta: """Metaclass options.""" - fields = [ - 'columns', - 'rows', - ] + fields = ['columns', 'rows'] # Mapping of columns - columns = serializers.ListField( - child=serializers.CharField( - allow_blank=True, - ), - ) + columns = serializers.ListField(child=serializers.CharField(allow_blank=True)) rows = serializers.ListField( child=serializers.ListField( - child=serializers.CharField( - allow_blank=True, - allow_null=True, - ), + child=serializers.CharField(allow_blank=True, allow_null=True) ) ) @@ -776,16 +758,9 @@ class DataFileExtractSerializer(serializers.Serializer): processed_row = self.process_row(self.row_to_dict(row)) if processed_row: - rows.append({ - "original": row, - "data": processed_row, - }) + rows.append({"original": row, "data": processed_row}) - return { - 'fields': model_fields, - 'columns': self.columns, - 'rows': rows, - } + return {'fields': model_fields, 'columns': self.columns, 'rows': rows} def process_row(self, row): """Process a 'row' of data, which is a mapped column:value dict. @@ -799,12 +774,9 @@ class DataFileExtractSerializer(serializers.Serializer): def row_to_dict(self, row): """Convert a "row" to a named data dict.""" - row_dict = { - 'errors': {}, - } + row_dict = {'errors': {}} for idx, value in enumerate(row): - if idx < len(self.columns): col = self.columns[idx] @@ -824,16 +796,16 @@ class DataFileExtractSerializer(serializers.Serializer): cols_seen = set() for name, field in model_fields.items(): - required = field.get('required', False) # Check for missing required columns if required: if name not in self.columns: - raise serializers.ValidationError(_(f"Missing required column: '{name}'")) + raise serializers.ValidationError( + _(f"Missing required column: '{name}'") + ) for col in self.columns: - if not col: continue @@ -856,9 +828,7 @@ class RemoteImageMixin(metaclass=serializers.SerializerMetaclass): def skip_create_fields(self): """Ensure the 'remote_image' field is skipped when creating a new instance""" - return [ - 'remote_image', - ] + return ['remote_image'] remote_image = serializers.URLField( required=False, @@ -877,8 +847,12 @@ class RemoteImageMixin(metaclass=serializers.SerializerMetaclass): if not url: return - if not common_models.InvenTreeSetting.get_setting('INVENTREE_DOWNLOAD_FROM_URL'): - raise ValidationError(_("Downloading images from remote URL is not enabled")) + if not common_models.InvenTreeSetting.get_setting( + 'INVENTREE_DOWNLOAD_FROM_URL' + ): + raise ValidationError( + _("Downloading images from remote URL is not enabled") + ) try: self.remote_image_file = download_image_from_url(url) diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 213f62734d..cd0a294a2c 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -38,9 +38,8 @@ INVENTREE_NEWS_URL = 'https://inventree.org/news/feed.atom' TESTING = 'test' in sys.argv or 'TESTING' in os.environ if TESTING: - # Use a weaker password hasher for testing (improves testing speed) - PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher',] + PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher'] # Enable slow-test-runner TEST_RUNNER = 'django_slowtests.testrunner.DiscoverSlowestTestsRunner' @@ -78,16 +77,17 @@ if version_file.exists(): # SECURITY WARNING: don't run with debug turned on in production! DEBUG = get_boolean_setting('INVENTREE_DEBUG', 'debug', True) -ENABLE_CLASSIC_FRONTEND = get_boolean_setting('INVENTREE_CLASSIC_FRONTEND', 'classic_frontend', True) -ENABLE_PLATFORM_FRONTEND = get_boolean_setting('INVENTREE_PLATFORM_FRONTEND', 'platform_frontend', True) +ENABLE_CLASSIC_FRONTEND = get_boolean_setting( + 'INVENTREE_CLASSIC_FRONTEND', 'classic_frontend', True +) +ENABLE_PLATFORM_FRONTEND = get_boolean_setting( + 'INVENTREE_PLATFORM_FRONTEND', 'platform_frontend', True +) # Configure logging settings log_level = get_setting('INVENTREE_LOG_LEVEL', 'log_level', 'WARNING') -logging.basicConfig( - level=log_level, - format="%(asctime)s %(levelname)s %(message)s", -) +logging.basicConfig(level=log_level, format="%(asctime)s %(levelname)s %(message)s") if log_level not in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']: log_level = 'WARNING' # pragma: no cover @@ -95,29 +95,18 @@ if log_level not in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']: LOGGING = { 'version': 1, 'disable_existing_loggers': False, - 'handlers': { - 'console': { - 'class': 'logging.StreamHandler', - }, - }, - 'root': { - 'handlers': ['console'], - 'level': log_level, - }, + 'handlers': {'console': {'class': 'logging.StreamHandler'}}, + 'root': {'handlers': ['console'], 'level': log_level}, 'filters': { 'require_not_maintenance_mode_503': { - '()': 'maintenance_mode.logging.RequireNotMaintenanceMode503', - }, + '()': 'maintenance_mode.logging.RequireNotMaintenanceMode503' + } }, } # Optionally add database-level logging if get_setting('INVENTREE_DB_LOGGING', 'db_logging', False): - LOGGING['loggers'] = { - 'django.db.backends': { - 'level': log_level or 'DEBUG', - }, - } + LOGGING['loggers'] = {'django.db.backends': {'level': log_level or 'DEBUG'}} # Get a logger instance for this setup file logger = logging.getLogger("inventree") @@ -146,9 +135,7 @@ CORS_URLS_REGEX = r'^/(api|media|static)/.*$' # Extract CORS options from configuration file CORS_ORIGIN_ALLOW_ALL = get_boolean_setting( - "INVENTREE_CORS_ORIGIN_ALLOW_ALL", - config_key='cors.allow_all', - default_value=False, + "INVENTREE_CORS_ORIGIN_ALLOW_ALL", config_key='cors.allow_all', default_value=False ) CORS_ORIGIN_WHITELIST = get_setting( @@ -173,9 +160,7 @@ STATICFILES_I18_TRG = BASE_DIR.joinpath('InvenTree', 'static_i18n') STATICFILES_DIRS.append(STATICFILES_I18_TRG) STATICFILES_I18_TRG = STATICFILES_I18_TRG.joinpath(STATICFILES_I18_PREFIX) -STATFILES_I18_PROCESSORS = [ - 'InvenTree.context.status_codes', -] +STATFILES_I18_PROCESSORS = ['InvenTree.context.status_codes'] # Color Themes Directory STATIC_COLOR_THEMES_DIR = STATIC_ROOT.joinpath('css', 'color-themes').resolve() @@ -189,38 +174,33 @@ DBBACKUP_SEND_EMAIL = False DBBACKUP_STORAGE = get_setting( 'INVENTREE_BACKUP_STORAGE', 'backup_storage', - 'django.core.files.storage.FileSystemStorage' + 'django.core.files.storage.FileSystemStorage', ) # Default backup configuration -DBBACKUP_STORAGE_OPTIONS = get_setting('INVENTREE_BACKUP_OPTIONS', 'backup_options', None) +DBBACKUP_STORAGE_OPTIONS = get_setting( + 'INVENTREE_BACKUP_OPTIONS', 'backup_options', None +) if DBBACKUP_STORAGE_OPTIONS is None: - DBBACKUP_STORAGE_OPTIONS = { - 'location': config.get_backup_dir(), - } + DBBACKUP_STORAGE_OPTIONS = {'location': config.get_backup_dir()} INVENTREE_ADMIN_ENABLED = get_boolean_setting( - 'INVENTREE_ADMIN_ENABLED', - config_key='admin_enabled', - default_value=True + 'INVENTREE_ADMIN_ENABLED', config_key='admin_enabled', default_value=True ) # Base URL for admin pages (default="admin") INVENTREE_ADMIN_URL = get_setting( - 'INVENTREE_ADMIN_URL', - config_key='admin_url', - default_value='admin' + 'INVENTREE_ADMIN_URL', config_key='admin_url', default_value='admin' ) INSTALLED_APPS = [ # Admin site integration 'django.contrib.admin', - # InvenTree apps 'build.apps.BuildConfig', 'common.apps.CommonConfig', 'company.apps.CompanyConfig', - 'plugin.apps.PluginAppConfig', # Plugin app runs before all apps that depend on the isPluginRegistryLoaded function + 'plugin.apps.PluginAppConfig', # Plugin app runs before all apps that depend on the isPluginRegistryLoaded function 'label.apps.LabelConfig', 'order.apps.OrderConfig', 'part.apps.PartConfig', @@ -229,80 +209,79 @@ INSTALLED_APPS = [ 'users.apps.UsersConfig', 'web', 'generic', - 'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last - + 'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last # Core django modules 'django.contrib.auth', 'django.contrib.contenttypes', - 'user_sessions', # db user sessions + 'user_sessions', # db user sessions 'django.contrib.messages', 'django.contrib.staticfiles', 'django.contrib.sites', - # Maintenance 'maintenance_mode', - # Third part add-ons - 'django_filters', # Extended filter functionality - 'rest_framework', # DRF (Django Rest Framework) - 'corsheaders', # Cross-origin Resource Sharing for DRF - 'crispy_forms', # Improved form rendering - 'import_export', # Import / export tables to file - 'django_cleanup.apps.CleanupConfig', # Automatically delete orphaned MEDIA files - 'mptt', # Modified Preorder Tree Traversal - 'markdownify', # Markdown template rendering - 'djmoney', # django-money integration - 'djmoney.contrib.exchange', # django-money exchange rates - 'error_report', # Error reporting in the admin interface + 'django_filters', # Extended filter functionality + 'rest_framework', # DRF (Django Rest Framework) + 'corsheaders', # Cross-origin Resource Sharing for DRF + 'crispy_forms', # Improved form rendering + 'import_export', # Import / export tables to file + 'django_cleanup.apps.CleanupConfig', # Automatically delete orphaned MEDIA files + 'mptt', # Modified Preorder Tree Traversal + 'markdownify', # Markdown template rendering + 'djmoney', # django-money integration + 'djmoney.contrib.exchange', # django-money exchange rates + 'error_report', # Error reporting in the admin interface 'django_q', - 'formtools', # Form wizard tools - 'dbbackup', # Backups - django-dbbackup - 'taggit', # Tagging - 'flags', # Flagging - django-flags - - 'allauth', # Base app for SSO - 'allauth.account', # Extend user with accounts - 'allauth.socialaccount', # Use 'social' providers - - 'django_otp', # OTP is needed for MFA - base package - 'django_otp.plugins.otp_totp', # Time based OTP - 'django_otp.plugins.otp_static', # Backup codes - - 'allauth_2fa', # MFA flow for allauth - 'dj_rest_auth', # Authentication APIs - dj-rest-auth - 'dj_rest_auth.registration', # Registration APIs - dj-rest-auth' - 'drf_spectacular', # API documentation - - 'django_ical', # For exporting calendars + 'formtools', # Form wizard tools + 'dbbackup', # Backups - django-dbbackup + 'taggit', # Tagging + 'flags', # Flagging - django-flags + 'allauth', # Base app for SSO + 'allauth.account', # Extend user with accounts + 'allauth.socialaccount', # Use 'social' providers + 'django_otp', # OTP is needed for MFA - base package + 'django_otp.plugins.otp_totp', # Time based OTP + 'django_otp.plugins.otp_static', # Backup codes + 'allauth_2fa', # MFA flow for allauth + 'dj_rest_auth', # Authentication APIs - dj-rest-auth + 'dj_rest_auth.registration', # Registration APIs - dj-rest-auth' + 'drf_spectacular', # API documentation + 'django_ical', # For exporting calendars ] -MIDDLEWARE = CONFIG.get('middleware', [ - 'django.middleware.security.SecurityMiddleware', - 'x_forwarded_for.middleware.XForwardedForMiddleware', - 'user_sessions.middleware.SessionMiddleware', # db user sessions - 'django.middleware.locale.LocaleMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'corsheaders.middleware.CorsMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'InvenTree.middleware.InvenTreeRemoteUserMiddleware', # Remote / proxy auth - 'django_otp.middleware.OTPMiddleware', # MFA support - 'InvenTree.middleware.CustomAllauthTwoFactorMiddleware', # Flow control for allauth - 'allauth.account.middleware.AccountMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'InvenTree.middleware.AuthRequiredMiddleware', - 'InvenTree.middleware.Check2FAMiddleware', # Check if the user should be forced to use MFA - 'maintenance_mode.middleware.MaintenanceModeMiddleware', - 'InvenTree.middleware.InvenTreeExceptionProcessor', # Error reporting -]) +MIDDLEWARE = CONFIG.get( + 'middleware', + [ + 'django.middleware.security.SecurityMiddleware', + 'x_forwarded_for.middleware.XForwardedForMiddleware', + 'user_sessions.middleware.SessionMiddleware', # db user sessions + 'django.middleware.locale.LocaleMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'corsheaders.middleware.CorsMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'InvenTree.middleware.InvenTreeRemoteUserMiddleware', # Remote / proxy auth + 'django_otp.middleware.OTPMiddleware', # MFA support + 'InvenTree.middleware.CustomAllauthTwoFactorMiddleware', # Flow control for allauth + 'allauth.account.middleware.AccountMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'InvenTree.middleware.AuthRequiredMiddleware', + 'InvenTree.middleware.Check2FAMiddleware', # Check if the user should be forced to use MFA + 'maintenance_mode.middleware.MaintenanceModeMiddleware', + 'InvenTree.middleware.InvenTreeExceptionProcessor', # Error reporting + ], +) -AUTHENTICATION_BACKENDS = CONFIG.get('authentication_backends', [ - 'django.contrib.auth.backends.RemoteUserBackend', # proxy login - 'django.contrib.auth.backends.ModelBackend', - 'allauth.account.auth_backends.AuthenticationBackend', # SSO login via external providers - "sesame.backends.ModelBackend", # Magic link login django-sesame -]) +AUTHENTICATION_BACKENDS = CONFIG.get( + 'authentication_backends', + [ + 'django.contrib.auth.backends.RemoteUserBackend', # proxy login + 'django.contrib.auth.backends.ModelBackend', + 'allauth.account.auth_backends.AuthenticationBackend', # SSO login via external providers + "sesame.backends.ModelBackend", # Magic link login django-sesame + ], +) # LDAP support LDAP_AUTH = get_boolean_setting("INVENTREE_LDAP_ENABLED", "ldap.enabled", False) @@ -317,10 +296,15 @@ if LDAP_AUTH: if LDAP_DEBUG: if "loggers" not in LOGGING: LOGGING["loggers"] = {} - LOGGING["loggers"]["django_auth_ldap"] = {"level": "DEBUG", "handlers": ["console"]} + LOGGING["loggers"]["django_auth_ldap"] = { + "level": "DEBUG", + "handlers": ["console"], + } # get global options from dict and use ldap.OPT_* as keys and values - global_options_dict = get_setting("INVENTREE_LDAP_GLOBAL_OPTIONS", "ldap.global_options", {}, dict) + global_options_dict = get_setting( + "INVENTREE_LDAP_GLOBAL_OPTIONS", "ldap.global_options", {}, dict + ) global_options = {} for k, v in global_options_dict.items(): # keys are always ldap.OPT_* constants @@ -344,22 +328,39 @@ if LDAP_AUTH: print("[LDAP] ldap.global_options =", global_options) AUTH_LDAP_SERVER_URI = get_setting("INVENTREE_LDAP_SERVER_URI", "ldap.server_uri") - AUTH_LDAP_START_TLS = get_boolean_setting("INVENTREE_LDAP_START_TLS", "ldap.start_tls", False) + AUTH_LDAP_START_TLS = get_boolean_setting( + "INVENTREE_LDAP_START_TLS", "ldap.start_tls", False + ) AUTH_LDAP_BIND_DN = get_setting("INVENTREE_LDAP_BIND_DN", "ldap.bind_dn") - AUTH_LDAP_BIND_PASSWORD = get_setting("INVENTREE_LDAP_BIND_PASSWORD", "ldap.bind_password") + AUTH_LDAP_BIND_PASSWORD = get_setting( + "INVENTREE_LDAP_BIND_PASSWORD", "ldap.bind_password" + ) AUTH_LDAP_USER_SEARCH = LDAPSearch( get_setting("INVENTREE_LDAP_SEARCH_BASE_DN", "ldap.search_base_dn"), ldap.SCOPE_SUBTREE, - str(get_setting("INVENTREE_LDAP_SEARCH_FILTER_STR", "ldap.search_filter_str", "(uid= %(user)s)")) + str( + get_setting( + "INVENTREE_LDAP_SEARCH_FILTER_STR", + "ldap.search_filter_str", + "(uid= %(user)s)", + ) + ), + ) + AUTH_LDAP_USER_DN_TEMPLATE = get_setting( + "INVENTREE_LDAP_USER_DN_TEMPLATE", "ldap.user_dn_template" + ) + AUTH_LDAP_USER_ATTR_MAP = get_setting( + "INVENTREE_LDAP_USER_ATTR_MAP", + "ldap.user_attr_map", + {'first_name': 'givenName', 'last_name': 'sn', 'email': 'mail'}, + dict, + ) + AUTH_LDAP_ALWAYS_UPDATE_USER = get_boolean_setting( + "INVENTREE_LDAP_ALWAYS_UPDATE_USER", "ldap.always_update_user", True + ) + AUTH_LDAP_CACHE_TIMEOUT = get_setting( + "INVENTREE_LDAP_CACHE_TIMEOUT", "ldap.cache_timeout", 3600, int ) - AUTH_LDAP_USER_DN_TEMPLATE = get_setting("INVENTREE_LDAP_USER_DN_TEMPLATE", "ldap.user_dn_template") - AUTH_LDAP_USER_ATTR_MAP = get_setting("INVENTREE_LDAP_USER_ATTR_MAP", "ldap.user_attr_map", { - 'first_name': 'givenName', - 'last_name': 'sn', - 'email': 'mail', - }, dict) - AUTH_LDAP_ALWAYS_UPDATE_USER = get_boolean_setting("INVENTREE_LDAP_ALWAYS_UPDATE_USER", "ldap.always_update_user", True) - AUTH_LDAP_CACHE_TIMEOUT = get_setting("INVENTREE_LDAP_CACHE_TIMEOUT", "ldap.cache_timeout", 3600, int) AUTH_LDAP_GROUP_SEARCH = LDAPSearch( get_setting("INVENTREE_LDAP_GROUP_SEARCH", "ldap.group_search"), @@ -367,12 +368,18 @@ if LDAP_AUTH: "(objectClass=groupOfUniqueNames)", ) AUTH_LDAP_GROUP_TYPE = GroupOfUniqueNamesType(name_attr="cn") - AUTH_LDAP_REQUIRE_GROUP = get_setting("INVENTREE_LDAP_REQUIRE_GROUP", "ldap.require_group") + AUTH_LDAP_REQUIRE_GROUP = get_setting( + "INVENTREE_LDAP_REQUIRE_GROUP", "ldap.require_group" + ) AUTH_LDAP_DENY_GROUP = get_setting("INVENTREE_LDAP_DENY_GROUP", "ldap.deny_group") - AUTH_LDAP_USER_FLAGS_BY_GROUP = get_setting("INVENTREE_LDAP_USER_FLAGS_BY_GROUP", "ldap.user_flags_by_group", {}, dict) + AUTH_LDAP_USER_FLAGS_BY_GROUP = get_setting( + "INVENTREE_LDAP_USER_FLAGS_BY_GROUP", "ldap.user_flags_by_group", {}, dict + ) AUTH_LDAP_FIND_GROUP_PERMS = True -DEBUG_TOOLBAR_ENABLED = DEBUG and get_setting('INVENTREE_DEBUG_TOOLBAR', 'debug_toolbar', False) +DEBUG_TOOLBAR_ENABLED = DEBUG and get_setting( + 'INVENTREE_DEBUG_TOOLBAR', 'debug_toolbar', False +) # If the debug toolbar is enabled, add the modules if DEBUG_TOOLBAR_ENABLED: # pragma: no cover @@ -386,9 +393,7 @@ if DEBUG_TOOLBAR_ENABLED: # pragma: no cover } # Internal IP addresses allowed to see the debug toolbar -INTERNAL_IPS = [ - '127.0.0.1', -] +INTERNAL_IPS = ['127.0.0.1'] # Internal flag to determine if we are running in docker mode DOCKER = get_boolean_setting('INVENTREE_DOCKER', default_value=False) @@ -396,7 +401,10 @@ DOCKER = get_boolean_setting('INVENTREE_DOCKER', default_value=False) if DOCKER: # pragma: no cover # Internal IP addresses are different when running under docker hostname, ___, ips = socket.gethostbyname_ex(socket.gethostname()) - INTERNAL_IPS = [ip[: ip.rfind(".")] + ".1" for ip in ips] + ["127.0.0.1", "10.0.2.2"] + INTERNAL_IPS = [ip[: ip.rfind(".")] + ".1" for ip in ips] + [ + "127.0.0.1", + "10.0.2.2", + ] # Allow secure http developer server in debug mode if DEBUG: @@ -426,15 +434,18 @@ TEMPLATES = [ 'InvenTree.context.status_codes', 'InvenTree.context.user_roles', ], - 'loaders': [( - 'InvenTree.template.InvenTreeTemplateLoader', [ - 'plugin.template.PluginTemplateLoader', - 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', - ]) + 'loaders': [ + ( + 'InvenTree.template.InvenTreeTemplateLoader', + [ + 'plugin.template.PluginTemplateLoader', + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', + ], + ) ], }, - }, + } ] if DEBUG_TOOLBAR_ENABLED: # pragma: no cover @@ -459,22 +470,24 @@ REST_FRAMEWORK = { ), 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', 'DEFAULT_METADATA_CLASS': 'InvenTree.metadata.InvenTreeMetadata', - 'DEFAULT_RENDERER_CLASSES': [ - 'rest_framework.renderers.JSONRenderer', - ], + 'DEFAULT_RENDERER_CLASSES': ['rest_framework.renderers.JSONRenderer'], 'TOKEN_MODEL': 'users.models.ApiToken', } if DEBUG: # Enable browsable API if in DEBUG mode - REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'].append('rest_framework.renderers.BrowsableAPIRenderer') + REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'].append( + 'rest_framework.renderers.BrowsableAPIRenderer' + ) # dj-rest-auth # JWT switch USE_JWT = get_boolean_setting('INVENTREE_USE_JWT', 'use_jwt', False) REST_USE_JWT = USE_JWT OLD_PASSWORD_FIELD_ENABLED = True -REST_AUTH_REGISTER_SERIALIZERS = {'REGISTER_SERIALIZER': 'InvenTree.forms.CustomRegisterSerializer'} +REST_AUTH_REGISTER_SERIALIZERS = { + 'REGISTER_SERIALIZER': 'InvenTree.forms.CustomRegisterSerializer' +} # JWT settings - rest_framework_simplejwt if USE_JWT: @@ -490,7 +503,10 @@ SPECTACULAR_SETTINGS = { 'TITLE': 'InvenTree API', 'DESCRIPTION': 'API for InvenTree - the intuitive open source inventory management system', 'LICENSE': {'MIT': 'https://github.com/inventree/InvenTree/blob/master/LICENSE'}, - 'EXTERNAL_DOCS': {'docs': 'https://docs.inventree.org', 'web': 'https://inventree.org'}, + 'EXTERNAL_DOCS': { + 'docs': 'https://docs.inventree.org', + 'web': 'https://inventree.org', + }, 'VERSION': inventreeApiVersion(), 'SERVE_INCLUDE_SCHEMA': False, } @@ -588,8 +604,10 @@ db_options = db_config.get("OPTIONS", db_config.get("options", {})) # Specific options for postgres backend if "postgres" in db_engine: # pragma: no cover - from psycopg2.extensions import (ISOLATION_LEVEL_READ_COMMITTED, - ISOLATION_LEVEL_SERIALIZABLE) + from psycopg2.extensions import ( + ISOLATION_LEVEL_READ_COMMITTED, + ISOLATION_LEVEL_SERIALIZABLE, + ) # Connection timeout if "connect_timeout" not in db_options: @@ -614,19 +632,29 @@ if "postgres" in db_engine: # pragma: no cover # Seconds after connection is idle to send keep alive if "keepalives_idle" not in db_options: db_options["keepalives_idle"] = int( - get_setting('INVENTREE_DB_TCP_KEEPALIVES_IDLE', 'database.tcp_keepalives_idle', 1) + get_setting( + 'INVENTREE_DB_TCP_KEEPALIVES_IDLE', 'database.tcp_keepalives_idle', 1 + ) ) # Seconds after missing ACK to send another keep alive if "keepalives_interval" not in db_options: db_options["keepalives_interval"] = int( - get_setting("INVENTREE_DB_TCP_KEEPALIVES_INTERVAL", "database.tcp_keepalives_internal", "1") + get_setting( + "INVENTREE_DB_TCP_KEEPALIVES_INTERVAL", + "database.tcp_keepalives_internal", + "1", + ) ) # Number of missing ACKs before we close the connection if "keepalives_count" not in db_options: db_options["keepalives_count"] = int( - get_setting("INVENTREE_DB_TCP_KEEPALIVES_COUNT", "database.tcp_keepalives_count", "5") + get_setting( + "INVENTREE_DB_TCP_KEEPALIVES_COUNT", + "database.tcp_keepalives_count", + "5", + ) ) # # Milliseconds for how long pending data should remain unacked @@ -641,8 +669,14 @@ if "postgres" in db_engine: # pragma: no cover # https://www.postgresql.org/docs/devel/transaction-iso.html # https://docs.djangoproject.com/en/3.2/ref/databases/#isolation-level if "isolation_level" not in db_options: - serializable = get_boolean_setting('INVENTREE_DB_ISOLATION_SERIALIZABLE', 'database.serializable', False) - db_options["isolation_level"] = ISOLATION_LEVEL_SERIALIZABLE if serializable else ISOLATION_LEVEL_READ_COMMITTED + serializable = get_boolean_setting( + 'INVENTREE_DB_ISOLATION_SERIALIZABLE', 'database.serializable', False + ) + db_options["isolation_level"] = ( + ISOLATION_LEVEL_SERIALIZABLE + if serializable + else ISOLATION_LEVEL_READ_COMMITTED + ) # Specific options for MySql / MariaDB backend elif "mysql" in db_engine: # pragma: no cover @@ -655,8 +689,12 @@ elif "mysql" in db_engine: # pragma: no cover # https://mariadb.com/kb/en/mariadb-transactions-and-isolation-levels-for-sql-server-users/#changing-the-isolation-level # https://docs.djangoproject.com/en/3.2/ref/databases/#mysql-isolation-level if "isolation_level" not in db_options: - serializable = get_boolean_setting('INVENTREE_DB_ISOLATION_SERIALIZABLE', 'database.serializable', False) - db_options["isolation_level"] = "serializable" if serializable else "read committed" + serializable = get_boolean_setting( + 'INVENTREE_DB_ISOLATION_SERIALIZABLE', 'database.serializable', False + ) + db_options["isolation_level"] = ( + "serializable" if serializable else "read committed" + ) # Specific options for sqlite backend elif "sqlite" in db_engine: @@ -672,31 +710,34 @@ elif "sqlite" in db_engine: db_config['OPTIONS'] = db_options # Set testing options for the database -db_config['TEST'] = { - 'CHARSET': 'utf8', -} +db_config['TEST'] = {'CHARSET': 'utf8'} # Set collation option for mysql test database if 'mysql' in db_engine: db_config['TEST']['COLLATION'] = 'utf8_general_ci' # pragma: no cover -DATABASES = { - 'default': db_config -} +DATABASES = {'default': db_config} # login settings -REMOTE_LOGIN = get_boolean_setting('INVENTREE_REMOTE_LOGIN', 'remote_login_enabled', False) -REMOTE_LOGIN_HEADER = get_setting('INVENTREE_REMOTE_LOGIN_HEADER', 'remote_login_header', 'REMOTE_USER') +REMOTE_LOGIN = get_boolean_setting( + 'INVENTREE_REMOTE_LOGIN', 'remote_login_enabled', False +) +REMOTE_LOGIN_HEADER = get_setting( + 'INVENTREE_REMOTE_LOGIN_HEADER', 'remote_login_header', 'REMOTE_USER' +) # sentry.io integration for error reporting -SENTRY_ENABLED = get_boolean_setting('INVENTREE_SENTRY_ENABLED', 'sentry_enabled', False) +SENTRY_ENABLED = get_boolean_setting( + 'INVENTREE_SENTRY_ENABLED', 'sentry_enabled', False +) # Default Sentry DSN (can be overridden if user wants custom sentry integration) SENTRY_DSN = get_setting('INVENTREE_SENTRY_DSN', 'sentry_dsn', default_sentry_dsn()) -SENTRY_SAMPLE_RATE = float(get_setting('INVENTREE_SENTRY_SAMPLE_RATE', 'sentry_sample_rate', 0.1)) +SENTRY_SAMPLE_RATE = float( + get_setting('INVENTREE_SENTRY_SAMPLE_RATE', 'sentry_sample_rate', 0.1) +) if SENTRY_ENABLED and SENTRY_DSN: # pragma: no cover - inventree_tags = { 'testing': TESTING, 'docker': DOCKER, @@ -719,19 +760,11 @@ if cache_host: # pragma: no cover "SOCKET_CONNECT_TIMEOUT": int(os.getenv("CACHE_CONNECT_TIMEOUT", "2")), "SOCKET_TIMEOUT": int(os.getenv("CACHE_SOCKET_TIMEOUT", "2")), "CONNECTION_POOL_KWARGS": { - "socket_keepalive": config.is_true( - os.getenv("CACHE_TCP_KEEPALIVE", "1") - ), + "socket_keepalive": config.is_true(os.getenv("CACHE_TCP_KEEPALIVE", "1")), "socket_keepalive_options": { - socket.TCP_KEEPCNT: int( - os.getenv("CACHE_KEEPALIVES_COUNT", "5") - ), - socket.TCP_KEEPIDLE: int( - os.getenv("CACHE_KEEPALIVES_IDLE", "1") - ), - socket.TCP_KEEPINTVL: int( - os.getenv("CACHE_KEEPALIVES_INTERVAL", "1") - ), + socket.TCP_KEEPCNT: int(os.getenv("CACHE_KEEPALIVES_COUNT", "5")), + socket.TCP_KEEPIDLE: int(os.getenv("CACHE_KEEPALIVES_IDLE", "1")), + socket.TCP_KEEPINTVL: int(os.getenv("CACHE_KEEPALIVES_INTERVAL", "1")), socket.TCP_USER_TIMEOUT: int( os.getenv("CACHE_TCP_USER_TIMEOUT", "1000") ), @@ -743,25 +776,27 @@ if cache_host: # pragma: no cover "BACKEND": "django_redis.cache.RedisCache", "LOCATION": f"redis://{cache_host}:{cache_port}/0", "OPTIONS": _cache_options, - }, + } } else: - CACHES = { - "default": { - "BACKEND": "django.core.cache.backends.locmem.LocMemCache", - }, - } + CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}} -_q_worker_timeout = int(get_setting('INVENTREE_BACKGROUND_TIMEOUT', 'background.timeout', 90)) +_q_worker_timeout = int( + get_setting('INVENTREE_BACKGROUND_TIMEOUT', 'background.timeout', 90) +) # django-q background worker configuration Q_CLUSTER = { 'name': 'InvenTree', 'label': 'Background Tasks', - 'workers': int(get_setting('INVENTREE_BACKGROUND_WORKERS', 'background.workers', 4)), + 'workers': int( + get_setting('INVENTREE_BACKGROUND_WORKERS', 'background.workers', 4) + ), 'timeout': _q_worker_timeout, 'retry': min(120, _q_worker_timeout + 30), - 'max_attempts': int(get_setting('INVENTREE_BACKGROUND_MAX_ATTEMPTS', 'background.max_attempts', 5)), + 'max_attempts': int( + get_setting('INVENTREE_BACKGROUND_MAX_ATTEMPTS', 'background.max_attempts', 5) + ), 'queue_limit': 50, 'catch_up': False, 'bulk': 10, @@ -773,11 +808,7 @@ Q_CLUSTER = { # Configure django-q sentry integration if SENTRY_ENABLED and SENTRY_DSN: - Q_CLUSTER['error_reporter'] = { - 'sentry': { - 'dsn': SENTRY_DSN - } - } + Q_CLUSTER['error_reporter'] = {'sentry': {'dsn': SENTRY_DSN}} if cache_host: # pragma: no cover # If using external redis cache, make the cache the broker for Django Q @@ -786,27 +817,21 @@ if cache_host: # pragma: no cover # database user sessions SESSION_ENGINE = 'user_sessions.backends.db' -LOGOUT_REDIRECT_URL = get_setting('INVENTREE_LOGOUT_REDIRECT_URL', 'logout_redirect_url', 'index') -SILENCED_SYSTEM_CHECKS = [ - 'admin.E410', -] +LOGOUT_REDIRECT_URL = get_setting( + 'INVENTREE_LOGOUT_REDIRECT_URL', 'logout_redirect_url', 'index' +) +SILENCED_SYSTEM_CHECKS = ['admin.E410'] # Password validation # https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator' }, + {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, + {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, + {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, ] # Extra (optional) URL validators @@ -873,19 +898,14 @@ if get_boolean_setting('TEST_TRANSLATIONS', default_value=False): # pragma: no LANGUAGES.append(('xx', 'Test')) # Add custom languages not provided by Django - EXTRA_LANG_INFO = { - 'xx': { - 'code': 'xx', - 'name': 'Test', - 'name_local': 'Test' - }, - } + EXTRA_LANG_INFO = {'xx': {'code': 'xx', 'name': 'Test', 'name_local': 'Test'}} LANG_INFO = dict(django.conf.locale.LANG_INFO, **EXTRA_LANG_INFO) django.conf.locale.LANG_INFO = LANG_INFO # Currencies available for use CURRENCIES = get_setting( - 'INVENTREE_CURRENCIES', 'currencies', + 'INVENTREE_CURRENCIES', + 'currencies', ['AUD', 'CAD', 'CNY', 'EUR', 'GBP', 'JPY', 'NZD', 'USD'], typecast=list, ) @@ -908,12 +928,18 @@ for currency in CURRENCIES: EXCHANGE_BACKEND = 'InvenTree.exchange.InvenTreeExchange' # Email configuration options -EMAIL_BACKEND = get_setting('INVENTREE_EMAIL_BACKEND', 'email.backend', 'django.core.mail.backends.smtp.EmailBackend') +EMAIL_BACKEND = get_setting( + 'INVENTREE_EMAIL_BACKEND', + 'email.backend', + 'django.core.mail.backends.smtp.EmailBackend', +) EMAIL_HOST = get_setting('INVENTREE_EMAIL_HOST', 'email.host', '') EMAIL_PORT = get_setting('INVENTREE_EMAIL_PORT', 'email.port', 25, typecast=int) EMAIL_HOST_USER = get_setting('INVENTREE_EMAIL_USERNAME', 'email.username', '') EMAIL_HOST_PASSWORD = get_setting('INVENTREE_EMAIL_PASSWORD', 'email.password', '') -EMAIL_SUBJECT_PREFIX = get_setting('INVENTREE_EMAIL_PREFIX', 'email.prefix', '[InvenTree] ') +EMAIL_SUBJECT_PREFIX = get_setting( + 'INVENTREE_EMAIL_PREFIX', 'email.prefix', '[InvenTree] ' +) EMAIL_USE_TLS = get_boolean_setting('INVENTREE_EMAIL_TLS', 'email.tls', False) EMAIL_USE_SSL = get_boolean_setting('INVENTREE_EMAIL_SSL', 'email.ssl', False) @@ -926,9 +952,7 @@ if not DEFAULT_FROM_EMAIL: EMAIL_USE_LOCALTIME = False EMAIL_TIMEOUT = 60 -LOCALE_PATHS = ( - BASE_DIR.joinpath('locale/'), -) +LOCALE_PATHS = (BASE_DIR.joinpath('locale/'),) TIME_ZONE = get_setting('INVENTREE_TIMEZONE', 'timezone', 'UTC') @@ -941,9 +965,7 @@ USE_L10N = True if not TESTING: USE_TZ = True # pragma: no cover -DATE_INPUT_FORMATS = [ - "%Y-%m-%d", -] +DATE_INPUT_FORMATS = ["%Y-%m-%d"] # crispy forms use the bootstrap templates CRISPY_TEMPLATE_PACK = 'bootstrap4' @@ -954,10 +976,11 @@ IMPORT_EXPORT_USE_TRANSACTIONS = True SITE_ID = 1 # Load the allauth social backends -SOCIAL_BACKENDS = get_setting('INVENTREE_SOCIAL_BACKENDS', 'social_backends', [], typecast=list) +SOCIAL_BACKENDS = get_setting( + 'INVENTREE_SOCIAL_BACKENDS', 'social_backends', [], typecast=list +) for app in SOCIAL_BACKENDS: - # Ensure that the app starts with 'allauth.socialaccount.providers' social_prefix = 'allauth.socialaccount.providers.' @@ -966,14 +989,22 @@ for app in SOCIAL_BACKENDS: INSTALLED_APPS.append(app) # pragma: no cover -SOCIALACCOUNT_PROVIDERS = get_setting('INVENTREE_SOCIAL_PROVIDERS', 'social_providers', None, typecast=dict) +SOCIALACCOUNT_PROVIDERS = get_setting( + 'INVENTREE_SOCIAL_PROVIDERS', 'social_providers', None, typecast=dict +) SOCIALACCOUNT_STORE_TOKENS = True # settings for allauth -ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = get_setting('INVENTREE_LOGIN_CONFIRM_DAYS', 'login_confirm_days', 3, typecast=int) -ACCOUNT_LOGIN_ATTEMPTS_LIMIT = get_setting('INVENTREE_LOGIN_ATTEMPTS', 'login_attempts', 5, typecast=int) -ACCOUNT_DEFAULT_HTTP_PROTOCOL = get_setting('INVENTREE_LOGIN_DEFAULT_HTTP_PROTOCOL', 'login_default_protocol', 'http') +ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = get_setting( + 'INVENTREE_LOGIN_CONFIRM_DAYS', 'login_confirm_days', 3, typecast=int +) +ACCOUNT_LOGIN_ATTEMPTS_LIMIT = get_setting( + 'INVENTREE_LOGIN_ATTEMPTS', 'login_attempts', 5, typecast=int +) +ACCOUNT_DEFAULT_HTTP_PROTOCOL = get_setting( + 'INVENTREE_LOGIN_DEFAULT_HTTP_PROTOCOL', 'login_default_protocol', 'http' +) ACCOUNT_LOGOUT_ON_PASSWORD_CHANGE = True ACCOUNT_PREVENT_ENUMERATION = True # 2FA @@ -1000,21 +1031,17 @@ ACCOUNT_ADAPTER = 'InvenTree.forms.CustomAccountAdapter' MARKDOWNIFY = { 'default': { 'BLEACH': True, - 'WHITELIST_ATTRS': [ - 'href', - 'src', - 'alt', - ], - 'MARKDOWN_EXTENSIONS': [ - 'markdown.extensions.extra' - ], + 'WHITELIST_ATTRS': ['href', 'src', 'alt'], + 'MARKDOWN_EXTENSIONS': ['markdown.extensions.extra'], 'WHITELIST_TAGS': [ 'a', 'abbr', 'b', 'blockquote', 'em', - 'h1', 'h2', 'h3', + 'h1', + 'h2', + 'h3', 'i', 'img', 'li', @@ -1027,32 +1054,37 @@ MARKDOWNIFY = { 'tbody', 'th', 'tr', - 'td' + 'td', ], } } # Ignore these error typeps for in-database error logging -IGNORED_ERRORS = [ - Http404, - django.core.exceptions.PermissionDenied, -] +IGNORED_ERRORS = [Http404, django.core.exceptions.PermissionDenied] # Maintenance mode MAINTENANCE_MODE_RETRY_AFTER = 60 MAINTENANCE_MODE_STATE_BACKEND = 'maintenance_mode.backends.StaticStorageBackend' # Are plugins enabled? -PLUGINS_ENABLED = get_boolean_setting('INVENTREE_PLUGINS_ENABLED', 'plugins_enabled', False) +PLUGINS_ENABLED = get_boolean_setting( + 'INVENTREE_PLUGINS_ENABLED', 'plugins_enabled', False +) PLUGIN_FILE = config.get_plugin_file() # Plugin test settings -PLUGIN_TESTING = get_setting('INVENTREE_PLUGIN_TESTING', 'PLUGIN_TESTING', TESTING) # Are plugins being tested? -PLUGIN_TESTING_SETUP = get_setting('INVENTREE_PLUGIN_TESTING_SETUP', 'PLUGIN_TESTING_SETUP', False) # Load plugins from setup hooks in testing? -PLUGIN_TESTING_EVENTS = False # Flag if events are tested right now -PLUGIN_RETRY = get_setting('INVENTREE_PLUGIN_RETRY', 'PLUGIN_RETRY', 5) # How often should plugin loading be tried? -PLUGIN_FILE_CHECKED = False # Was the plugin file checked? +PLUGIN_TESTING = get_setting( + 'INVENTREE_PLUGIN_TESTING', 'PLUGIN_TESTING', TESTING +) # Are plugins being tested? +PLUGIN_TESTING_SETUP = get_setting( + 'INVENTREE_PLUGIN_TESTING_SETUP', 'PLUGIN_TESTING_SETUP', False +) # Load plugins from setup hooks in testing? +PLUGIN_TESTING_EVENTS = False # Flag if events are tested right now +PLUGIN_RETRY = get_setting( + 'INVENTREE_PLUGIN_RETRY', 'PLUGIN_RETRY', 5 +) # How often should plugin loading be tried? +PLUGIN_FILE_CHECKED = False # Was the plugin file checked? # Site URL can be specified statically, or via a run-time setting SITE_URL = get_setting('INVENTREE_SITE_URL', 'site_url', None) @@ -1065,8 +1097,12 @@ if SITE_URL: validator(SITE_URL) # User interface customization values -CUSTOM_LOGO = get_custom_file('INVENTREE_CUSTOM_LOGO', 'customize.logo', 'custom logo', lookup_media=True) -CUSTOM_SPLASH = get_custom_file('INVENTREE_CUSTOM_SPLASH', 'customize.splash', 'custom splash') +CUSTOM_LOGO = get_custom_file( + 'INVENTREE_CUSTOM_LOGO', 'customize.logo', 'custom logo', lookup_media=True +) +CUSTOM_SPLASH = get_custom_file( + 'INVENTREE_CUSTOM_SPLASH', 'customize.splash', 'custom splash' +) CUSTOMIZE = get_setting('INVENTREE_CUSTOMIZE', 'customize', {}) @@ -1087,7 +1123,7 @@ FLAGS = { {'condition': 'parameter', 'value': 'experimental='}, ], # Should experimental features be turned on? 'NEXT_GEN': [ - {'condition': 'parameter', 'value': 'ngen='}, + {'condition': 'parameter', 'value': 'ngen='} ], # Should next-gen features be turned on? } diff --git a/InvenTree/InvenTree/social_auth_urls.py b/InvenTree/InvenTree/social_auth_urls.py index 5eca51210e..a5ca10c777 100644 --- a/InvenTree/InvenTree/social_auth_urls.py +++ b/InvenTree/InvenTree/social_auth_urls.py @@ -1,4 +1,5 @@ """API endpoints for social authentication with allauth.""" + import logging from importlib import import_module @@ -6,8 +7,7 @@ from django.urls import NoReverseMatch, include, path, reverse from allauth.account.models import EmailAddress from allauth.socialaccount import providers -from allauth.socialaccount.providers.oauth2.views import (OAuth2Adapter, - OAuth2LoginView) +from allauth.socialaccount.providers.oauth2.views import OAuth2Adapter, OAuth2LoginView from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework.exceptions import NotFound from rest_framework.permissions import AllowAny, IsAuthenticated @@ -23,6 +23,7 @@ logger = logging.getLogger('inventree') class GenericOAuth2ApiLoginView(OAuth2LoginView): """Api view to login a user with a social account""" + def dispatch(self, request, *args, **kwargs): """Dispatch the regular login view directly.""" return self.login(request, *args, **kwargs) @@ -44,8 +45,16 @@ class GenericOAuth2ApiConnectView(GenericOAuth2ApiLoginView): def handle_oauth2(adapter: OAuth2Adapter): """Define urls for oauth2 endpoints.""" return [ - path('login/', GenericOAuth2ApiLoginView.adapter_view(adapter), name=f'{provider.id}_api_login'), - path('connect/', GenericOAuth2ApiConnectView.adapter_view(adapter), name=f'{provider.id}_api_connect'), + path( + 'login/', + GenericOAuth2ApiLoginView.adapter_view(adapter), + name=f'{provider.id}_api_login', + ), + path( + 'connect/', + GenericOAuth2ApiConnectView.adapter_view(adapter), + name=f'{provider.id}_api_connect', + ), ] @@ -64,7 +73,6 @@ social_auth_urlpatterns = [] provider_urlpatterns = [] for name, provider in providers.registry.provider_map.items(): - try: prov_mod = import_module(provider.get_package() + ".views") except ImportError: @@ -72,7 +80,13 @@ for name, provider in providers.registry.provider_map.items(): continue # Try to extract the adapter class - adapters = [cls for cls in prov_mod.__dict__.values() if isinstance(cls, type) and not cls == OAuth2Adapter and issubclass(cls, OAuth2Adapter)] + adapters = [ + cls + for cls in prov_mod.__dict__.values() + if isinstance(cls, type) + and not cls == OAuth2Adapter + and issubclass(cls, OAuth2Adapter) + ] # Get urls urls = [] @@ -80,10 +94,17 @@ for name, provider in providers.registry.provider_map.items(): urls = handle_oauth2(adapter=adapters[0]) else: if provider.id in legacy: - logger.warning('`%s` is not supported on platform UI. Use `%s` instead.', provider.id, legacy[provider.id]) + logger.warning( + '`%s` is not supported on platform UI. Use `%s` instead.', + provider.id, + legacy[provider.id], + ) continue else: - logger.error('Found handler that is not yet ready for platform UI: `%s`. Open an feature request on GitHub if you need it implemented.', provider.id) + logger.error( + 'Found handler that is not yet ready for platform UI: `%s`. Open an feature request on GitHub if you need it implemented.', + provider.id, + ) continue provider_urlpatterns += [path(f'{provider.id}/', include(urls))] @@ -93,6 +114,7 @@ social_auth_urlpatterns += provider_urlpatterns class SocialProviderListView(ListAPI): """List of available social providers.""" + permission_classes = (AllowAny,) def get(self, request, *args, **kwargs): @@ -102,21 +124,27 @@ class SocialProviderListView(ListAPI): provider_data = { 'id': provider.id, 'name': provider.name, - 'configured': False + 'configured': False, } try: - provider_data['login'] = request.build_absolute_uri(reverse(f'{provider.id}_api_login')) + provider_data['login'] = request.build_absolute_uri( + reverse(f'{provider.id}_api_login') + ) except NoReverseMatch: provider_data['login'] = None try: - provider_data['connect'] = request.build_absolute_uri(reverse(f'{provider.id}_api_connect')) + provider_data['connect'] = request.build_absolute_uri( + reverse(f'{provider.id}_api_connect') + ) except NoReverseMatch: provider_data['connect'] = None provider_data['configured'] = InvenTree.sso.check_provider(provider) - provider_data['display_name'] = InvenTree.sso.provider_display_name(provider) + provider_data['display_name'] = InvenTree.sso.provider_display_name( + provider + ) provider_list.append(provider_data) @@ -124,7 +152,7 @@ class SocialProviderListView(ListAPI): 'sso_enabled': InvenTree.sso.login_enabled(), 'sso_registration': InvenTree.sso.registration_enabled(), 'mfa_required': InvenTreeSetting.get_setting('LOGIN_ENFORCE_MFA'), - 'providers': provider_list + 'providers': provider_list, } return Response(data) @@ -151,6 +179,7 @@ class EmptyEmailAddressSerializer(InvenTreeModelSerializer): class EmailListView(ListCreateAPI): """List of registered email addresses for current users.""" + permission_classes = (IsAuthenticated,) serializer_class = EmailAddressSerializer @@ -161,12 +190,15 @@ class EmailListView(ListCreateAPI): class EmailActionMixin(CreateAPI): """Mixin to modify email addresses for current users.""" + serializer_class = EmptyEmailAddressSerializer permission_classes = (IsAuthenticated,) def get_queryset(self): """Filter queryset for current user.""" - return EmailAddress.objects.filter(user=self.request.user, pk=self.kwargs['pk']).first() + return EmailAddress.objects.filter( + user=self.request.user, pk=self.kwargs['pk'] + ).first() @extend_schema(responses={200: OpenApiResponse(response=EmailAddressSerializer)}) def post(self, request, *args, **kwargs): diff --git a/InvenTree/InvenTree/sso.py b/InvenTree/InvenTree/sso.py index 3fc126e3e4..57fffa367d 100644 --- a/InvenTree/InvenTree/sso.py +++ b/InvenTree/InvenTree/sso.py @@ -1,6 +1,5 @@ """Helper functions for Single Sign On functionality""" - import logging from common.models import InvenTreeSetting diff --git a/InvenTree/InvenTree/status.py b/InvenTree/InvenTree/status.py index 767f091735..bd23ac1fa6 100644 --- a/InvenTree/InvenTree/status.py +++ b/InvenTree/InvenTree/status.py @@ -33,9 +33,7 @@ def is_worker_running(**kwargs): now = timezone.now() past = now - timedelta(minutes=10) - results = Success.objects.filter( - started__gte=past - ) + results = Success.objects.filter(started__gte=past) # If any results are returned, then the background worker is running! try: diff --git a/InvenTree/InvenTree/status_codes.py b/InvenTree/InvenTree/status_codes.py index 956773a9e6..966833f78f 100644 --- a/InvenTree/InvenTree/status_codes.py +++ b/InvenTree/InvenTree/status_codes.py @@ -9,68 +9,68 @@ class PurchaseOrderStatus(StatusCode): """Defines a set of status codes for a PurchaseOrder.""" # Order status codes - PENDING = 10, _("Pending"), 'secondary' # Order is pending (not yet placed) - PLACED = 20, _("Placed"), 'primary' # Order has been placed with supplier - COMPLETE = 30, _("Complete"), 'success' # Order has been completed - CANCELLED = 40, _("Cancelled"), 'danger' # Order was cancelled - LOST = 50, _("Lost"), 'warning' # Order was lost - RETURNED = 60, _("Returned"), 'warning' # Order was returned + PENDING = 10, _("Pending"), 'secondary' # Order is pending (not yet placed) + PLACED = 20, _("Placed"), 'primary' # Order has been placed with supplier + COMPLETE = 30, _("Complete"), 'success' # Order has been completed + CANCELLED = 40, _("Cancelled"), 'danger' # Order was cancelled + LOST = 50, _("Lost"), 'warning' # Order was lost + RETURNED = 60, _("Returned"), 'warning' # Order was returned class PurchaseOrderStatusGroups: """Groups for PurchaseOrderStatus codes.""" # Open orders - OPEN = [ - PurchaseOrderStatus.PENDING.value, - PurchaseOrderStatus.PLACED.value, - ] + OPEN = [PurchaseOrderStatus.PENDING.value, PurchaseOrderStatus.PLACED.value] # Failed orders FAILED = [ PurchaseOrderStatus.CANCELLED.value, PurchaseOrderStatus.LOST.value, - PurchaseOrderStatus.RETURNED.value + PurchaseOrderStatus.RETURNED.value, ] class SalesOrderStatus(StatusCode): """Defines a set of status codes for a SalesOrder.""" - PENDING = 10, _("Pending"), 'secondary' # Order is pending - IN_PROGRESS = 15, _("In Progress"), 'primary' # Order has been issued, and is in progress - SHIPPED = 20, _("Shipped"), 'success' # Order has been shipped to customer - CANCELLED = 40, _("Cancelled"), 'danger' # Order has been cancelled - LOST = 50, _("Lost"), 'warning' # Order was lost - RETURNED = 60, _("Returned"), 'warning' # Order was returned + PENDING = 10, _("Pending"), 'secondary' # Order is pending + IN_PROGRESS = ( + 15, + _("In Progress"), + 'primary', + ) # Order has been issued, and is in progress + SHIPPED = 20, _("Shipped"), 'success' # Order has been shipped to customer + CANCELLED = 40, _("Cancelled"), 'danger' # Order has been cancelled + LOST = 50, _("Lost"), 'warning' # Order was lost + RETURNED = 60, _("Returned"), 'warning' # Order was returned class SalesOrderStatusGroups: """Groups for SalesOrderStatus codes.""" # Open orders - OPEN = [ - SalesOrderStatus.PENDING.value, - SalesOrderStatus.IN_PROGRESS.value, - ] + OPEN = [SalesOrderStatus.PENDING.value, SalesOrderStatus.IN_PROGRESS.value] # Completed orders - COMPLETE = [ - SalesOrderStatus.SHIPPED.value, - ] + COMPLETE = [SalesOrderStatus.SHIPPED.value] class StockStatus(StatusCode): """Status codes for Stock.""" - OK = 10, _("OK"), 'success' # Item is OK - ATTENTION = 50, _("Attention needed"), 'warning' # Item requires attention - DAMAGED = 55, _("Damaged"), 'warning' # Item is damaged - DESTROYED = 60, _("Destroyed"), 'danger' # Item is destroyed - REJECTED = 65, _("Rejected"), 'danger' # Item is rejected - LOST = 70, _("Lost"), 'dark' # Item has been lost - QUARANTINED = 75, _("Quarantined"), 'info' # Item has been quarantined and is unavailable - RETURNED = 85, _("Returned"), 'warning' # Item has been returned from a customer + OK = 10, _("OK"), 'success' # Item is OK + ATTENTION = 50, _("Attention needed"), 'warning' # Item requires attention + DAMAGED = 55, _("Damaged"), 'warning' # Item is damaged + DESTROYED = 60, _("Destroyed"), 'danger' # Item is destroyed + REJECTED = 65, _("Rejected"), 'danger' # Item is rejected + LOST = 70, _("Lost"), 'dark' # Item has been lost + QUARANTINED = ( + 75, + _("Quarantined"), + 'info', + ) # Item has been quarantined and is unavailable + RETURNED = 85, _("Returned"), 'warning' # Item has been returned from a customer class StockStatusGroups: @@ -145,19 +145,16 @@ class StockHistoryCode(StatusCode): class BuildStatus(StatusCode): """Build status codes.""" - PENDING = 10, _("Pending"), 'secondary' # Build is pending / active - PRODUCTION = 20, _("Production"), 'primary' # BuildOrder is in production - CANCELLED = 30, _("Cancelled"), 'danger' # Build was cancelled - COMPLETE = 40, _("Complete"), 'success' # Build is complete + PENDING = 10, _("Pending"), 'secondary' # Build is pending / active + PRODUCTION = 20, _("Production"), 'primary' # BuildOrder is in production + CANCELLED = 30, _("Cancelled"), 'danger' # Build was cancelled + COMPLETE = 40, _("Complete"), 'success' # Build is complete class BuildStatusGroups: """Groups for BuildStatus codes.""" - ACTIVE_CODES = [ - BuildStatus.PENDING.value, - BuildStatus.PRODUCTION.value, - ] + ACTIVE_CODES = [BuildStatus.PENDING.value, BuildStatus.PRODUCTION.value] class ReturnOrderStatus(StatusCode): @@ -176,10 +173,7 @@ class ReturnOrderStatus(StatusCode): class ReturnOrderStatusGroups: """Groups for ReturnOrderStatus codes.""" - OPEN = [ - ReturnOrderStatus.PENDING.value, - ReturnOrderStatus.IN_PROGRESS.value, - ] + OPEN = [ReturnOrderStatus.PENDING.value, ReturnOrderStatus.IN_PROGRESS.value] class ReturnOrderLineStatus(StatusCode): diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index 3bbc36be12..975065a295 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -16,13 +16,15 @@ from django.core.exceptions import AppRegistryNotReady from django.core.management import call_command from django.db import DEFAULT_DB_ALIAS, connections from django.db.migrations.executor import MigrationExecutor -from django.db.utils import (NotSupportedError, OperationalError, - ProgrammingError) +from django.db.utils import NotSupportedError, OperationalError, ProgrammingError from django.utils import timezone import requests -from maintenance_mode.core import (get_maintenance_mode, maintenance_mode_on, - set_maintenance_mode) +from maintenance_mode.core import ( + get_maintenance_mode, + maintenance_mode_on, + set_maintenance_mode, +) from InvenTree.config import get_setting from plugin import registry @@ -57,11 +59,7 @@ def schedule_task(taskname, **kwargs): else: logger.info("Creating scheduled task '%s'", taskname) - Schedule.objects.create( - name=taskname, - func=taskname, - **kwargs - ) + Schedule.objects.create(name=taskname, func=taskname, **kwargs) except (OperationalError, ProgrammingError): # pragma: no cover # Required if the DB is not ready yet pass @@ -96,7 +94,9 @@ def check_daily_holdoff(task_name: str, n_days: int = 1) -> bool: from InvenTree.ready import isInTestMode if n_days <= 0: - logger.info("Specified interval for task '%s' < 1 - task will not run", task_name) + logger.info( + "Specified interval for task '%s' < 1 - task will not run", task_name + ) return False # Sleep a random number of seconds to prevent worker conflict @@ -119,7 +119,9 @@ def check_daily_holdoff(task_name: str, n_days: int = 1) -> bool: threshold = datetime.now() - timedelta(days=n_days) if last_success > threshold: - logger.info("Last successful run for '%s' was too recent - skipping task", task_name) + logger.info( + "Last successful run for '%s' was too recent - skipping task", task_name + ) return False # Check for any information we have about this task @@ -136,7 +138,9 @@ def check_daily_holdoff(task_name: str, n_days: int = 1) -> bool: threshold = datetime.now() - timedelta(hours=12) if last_attempt > threshold: - logger.info("Last attempt for '%s' was too recent - skipping task", task_name) + logger.info( + "Last attempt for '%s' was too recent - skipping task", task_name + ) return False # Record this attempt @@ -152,17 +156,23 @@ def record_task_attempt(task_name: str): logger.info("Logging task attempt for '%s'", task_name) - InvenTreeSetting.set_setting(f'_{task_name}_ATTEMPT', datetime.now().isoformat(), None) + InvenTreeSetting.set_setting( + f'_{task_name}_ATTEMPT', datetime.now().isoformat(), None + ) def record_task_success(task_name: str): """Record that a multi-day task was successful *now*""" from common.models import InvenTreeSetting - InvenTreeSetting.set_setting(f'_{task_name}_SUCCESS', datetime.now().isoformat(), None) + InvenTreeSetting.set_setting( + f'_{task_name}_SUCCESS', datetime.now().isoformat(), None + ) -def offload_task(taskname, *args, force_async=False, force_sync=False, **kwargs) -> bool: +def offload_task( + taskname, *args, force_async=False, force_sync=False, **kwargs +) -> bool: """Create an AsyncTask if workers are running. This is different to a 'scheduled' task, in that it only runs once! If workers are not running or force_sync flag, is set then the task is ran synchronously. @@ -205,7 +215,6 @@ def offload_task(taskname, *args, force_async=False, force_sync=False, **kwargs) raise_warning(f"WARNING: '{taskname}' not offloaded due to {str(exc)}") return False else: - if callable(taskname): # function was passed - use that _func = taskname @@ -215,14 +224,18 @@ def offload_task(taskname, *args, force_async=False, force_sync=False, **kwargs) app, mod, func = taskname.split('.') app_mod = app + '.' + mod except ValueError: - raise_warning(f"WARNING: '{taskname}' not started - Malformed function path") + raise_warning( + f"WARNING: '{taskname}' not started - Malformed function path" + ) return False # Import module from app try: _mod = importlib.import_module(app_mod) except ModuleNotFoundError: - raise_warning(f"WARNING: '{taskname}' not started - No module named '{app_mod}'") + raise_warning( + f"WARNING: '{taskname}' not started - No module named '{app_mod}'" + ) return False # Retrieve function @@ -236,7 +249,9 @@ def offload_task(taskname, *args, force_async=False, force_sync=False, **kwargs) if not _func: _func = eval(func) # pragma: no cover except NameError: - raise_warning(f"WARNING: '{taskname}' not started - No function named '{func}'") + raise_warning( + f"WARNING: '{taskname}' not started - No function named '{func}'" + ) return False # Workers are not running: run it as synchronous task @@ -275,6 +290,7 @@ class ScheduledTask: class TaskRegister: """Registry for periodic tasks.""" + task_list: List[ScheduledTask] = [] def register(self, task, schedule, minutes: int = None): @@ -320,6 +336,7 @@ def scheduled_task(interval: str, minutes: int = None, tasklist: TaskRegister = _tasks.register(admin_class, interval, minutes=minutes) return admin_class + return _task_wrapper @@ -340,8 +357,7 @@ def heartbeat(): # Delete heartbeat results more than half an hour old, # otherwise they just create extra noise heartbeats = Success.objects.filter( - func='InvenTree.tasks.heartbeat', - started__lte=threshold + func='InvenTree.tasks.heartbeat', started__lte=threshold ) heartbeats.delete() @@ -359,16 +375,16 @@ def delete_successful_tasks(): threshold = timezone.now() - timedelta(days=days) # Delete successful tasks - results = Success.objects.filter( - started__lte=threshold - ) + results = Success.objects.filter(started__lte=threshold) if results.count() > 0: logger.info("Deleting %s successful task records", results.count()) results.delete() except AppRegistryNotReady: # pragma: no cover - logger.info("Could not perform 'delete_successful_tasks' - App registry not ready") + logger.info( + "Could not perform 'delete_successful_tasks' - App registry not ready" + ) @scheduled_task(ScheduledTask.DAILY) @@ -383,9 +399,7 @@ def delete_failed_tasks(): threshold = timezone.now() - timedelta(days=days) # Delete failed tasks - results = Failure.objects.filter( - started__lte=threshold - ) + results = Failure.objects.filter(started__lte=threshold) if results.count() > 0: logger.info("Deleting %s failed task records", results.count()) @@ -406,9 +420,7 @@ def delete_old_error_logs(): days = InvenTreeSetting.get_setting('INVENTREE_DELETE_ERRORS_DAYS', 30) threshold = timezone.now() - timedelta(days=days) - errors = Error.objects.filter( - when__lte=threshold, - ) + errors = Error.objects.filter(when__lte=threshold) if errors.count() > 0: logger.info("Deleting %s old error logs", errors.count()) @@ -416,37 +428,40 @@ def delete_old_error_logs(): except AppRegistryNotReady: # pragma: no cover # Apps not yet loaded - logger.info("Could not perform 'delete_old_error_logs' - App registry not ready") + logger.info( + "Could not perform 'delete_old_error_logs' - App registry not ready" + ) @scheduled_task(ScheduledTask.DAILY) def delete_old_notifications(): """Delete old notification logs""" try: - from common.models import (InvenTreeSetting, NotificationEntry, - NotificationMessage) + from common.models import ( + InvenTreeSetting, + NotificationEntry, + NotificationMessage, + ) days = InvenTreeSetting.get_setting('INVENTREE_DELETE_NOTIFICATIONS_DAYS', 30) threshold = timezone.now() - timedelta(days=days) - items = NotificationEntry.objects.filter( - updated__lte=threshold - ) + items = NotificationEntry.objects.filter(updated__lte=threshold) if items.count() > 0: logger.info("Deleted %s old notification entries", items.count()) items.delete() - items = NotificationMessage.objects.filter( - creation__lte=threshold - ) + items = NotificationMessage.objects.filter(creation__lte=threshold) if items.count() > 0: logger.info("Deleted %s old notification messages", items.count()) items.delete() except AppRegistryNotReady: - logger.info("Could not perform 'delete_old_notifications' - App registry not ready") + logger.info( + "Could not perform 'delete_old_notifications' - App registry not ready" + ) @scheduled_task(ScheduledTask.DAILY) @@ -460,7 +475,11 @@ def check_for_updates(): logger.info("Could not perform 'check_for_updates' - App registry not ready") return - interval = int(common.models.InvenTreeSetting.get_setting('INVENTREE_UPDATE_CHECK_INTERVAL', 7, cache=False)) + interval = int( + common.models.InvenTreeSetting.get_setting( + 'INVENTREE_UPDATE_CHECK_INTERVAL', 7, cache=False + ) + ) # Check if we should check for updates *today* if not check_daily_holdoff('check_for_updates', interval): @@ -479,11 +498,13 @@ def check_for_updates(): response = requests.get( 'https://api.github.com/repos/inventree/inventree/releases/latest', - headers=headers + headers=headers, ) if response.status_code != 200: - raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}') # pragma: no cover + raise ValueError( + f'Unexpected status code from GitHub API: {response.status_code}' + ) # pragma: no cover data = json.loads(response.text) @@ -506,11 +527,7 @@ def check_for_updates(): logger.info("Latest InvenTree version: '%s'", tag) # Save the version to the database - common.models.InvenTreeSetting.set_setting( - '_INVENTREE_LATEST_VERSION', - tag, - None - ) + common.models.InvenTreeSetting.set_setting('_INVENTREE_LATEST_VERSION', tag, None) # Record that this task was successful record_task_success('check_for_updates') @@ -529,8 +546,7 @@ def check_for_updates(): return # Send notification trigger_superuser_notification( - plg, - f'An update for InvenTree to version {tag} is available', + plg, f'An update for InvenTree to version {tag} is available' ) @@ -549,14 +565,18 @@ def update_exchange_rates(force: bool = False): from InvenTree.exchange import InvenTreeExchange except AppRegistryNotReady: # pragma: no cover # Apps not yet loaded! - logger.info("Could not perform 'update_exchange_rates' - App registry not ready") + logger.info( + "Could not perform 'update_exchange_rates' - App registry not ready" + ) return except Exception as exc: # pragma: no cover logger.info("Could not perform 'update_exchange_rates' - %s", exc) return if not force: - interval = int(InvenTreeSetting.get_setting('CURRENCY_UPDATE_INTERVAL', 1, cache=False)) + interval = int( + InvenTreeSetting.get_setting('CURRENCY_UPDATE_INTERVAL', 1, cache=False) + ) if not check_daily_holdoff('update_exchange_rates', interval): logger.info("Skipping exchange rate update (interval not reached)") @@ -570,7 +590,9 @@ def update_exchange_rates(force: bool = False): backend.update_rates(base_currency=base) # Remove any exchange rates which are not in the provided currencies - Rate.objects.filter(backend="InvenTreeExchange").exclude(currency__in=currency_codes()).delete() + Rate.objects.filter(backend="InvenTreeExchange").exclude( + currency__in=currency_codes() + ).delete() # Record successful task execution record_task_success('update_exchange_rates') @@ -590,7 +612,9 @@ def run_backup(): # Backups are not enabled - exit early return - interval = int(InvenTreeSetting.get_setting('INVENTREE_BACKUP_DAYS', 1, cache=False)) + interval = int( + InvenTreeSetting.get_setting('INVENTREE_BACKUP_DAYS', 1, cache=False) + ) # Check if should run this task *today* if not check_daily_holdoff('run_backup', interval): @@ -599,7 +623,9 @@ def run_backup(): logger.info("Performing automated database backup task") call_command("dbbackup", noinput=True, clean=True, compress=True, interactive=False) - call_command("mediabackup", noinput=True, clean=True, compress=True, interactive=False) + call_command( + "mediabackup", noinput=True, clean=True, compress=True, interactive=False + ) # Record that this task was successful record_task_success('run_backup') diff --git a/InvenTree/InvenTree/test_api.py b/InvenTree/InvenTree/test_api.py index aa7907d926..4df2dc6907 100644 --- a/InvenTree/InvenTree/test_api.py +++ b/InvenTree/InvenTree/test_api.py @@ -17,6 +17,7 @@ class HTMLAPITests(InvenTreeTestCase): which raised an AssertionError when using the HTML API interface, while the regular JSON interface continued to work as expected. """ + roles = 'all' def test_part_api(self): @@ -60,12 +61,7 @@ class HTMLAPITests(InvenTreeTestCase): class APITests(InvenTreeAPITestCase): """Tests for the InvenTree API.""" - fixtures = [ - 'location', - 'category', - 'part', - 'stock' - ] + fixtures = ['location', 'category', 'part', 'stock'] token = None auto_login = False @@ -270,33 +266,20 @@ class BulkDeleteTests(InvenTreeAPITestCase): url = reverse('api-stock-test-result-list') # DELETE without any of the required fields - response = self.delete( - url, - {}, - expected_code=400 - ) + response = self.delete(url, {}, expected_code=400) - self.assertIn('List of items or filters must be provided for bulk deletion', str(response.data)) + self.assertIn( + 'List of items or filters must be provided for bulk deletion', + str(response.data), + ) # DELETE with invalid 'items' - response = self.delete( - url, - { - 'items': {"hello": "world"}, - }, - expected_code=400, - ) + response = self.delete(url, {'items': {"hello": "world"}}, expected_code=400) self.assertIn("'items' must be supplied as a list object", str(response.data)) # DELETE with invalid 'filters' - response = self.delete( - url, - { - 'filters': [1, 2, 3], - }, - expected_code=400, - ) + response = self.delete(url, {'filters': [1, 2, 3]}, expected_code=400) self.assertIn("'filters' must be supplied as a dict object", str(response.data)) @@ -317,11 +300,7 @@ class SearchTests(InvenTreeAPITestCase): def test_empty(self): """Test empty request""" - data = [ - '', - None, - {}, - ] + data = ['', None, {}] for d in data: response = self.post(reverse('api-search'), d, expected_code=400) @@ -331,13 +310,8 @@ class SearchTests(InvenTreeAPITestCase): """Test individual result types""" response = self.post( reverse('api-search'), - { - 'search': 'chair', - 'limit': 3, - 'part': {}, - 'build': {}, - }, - expected_code=200 + {'search': 'chair', 'limit': 3, 'part': {}, 'build': {}}, + expected_code=200, ) # No build results @@ -354,12 +328,7 @@ class SearchTests(InvenTreeAPITestCase): # Search for orders response = self.post( reverse('api-search'), - { - 'search': '01', - 'limit': 2, - 'purchaseorder': {}, - 'salesorder': {}, - }, + {'search': '01', 'limit': 2, 'purchaseorder': {}, 'salesorder': {}}, expected_code=200, ) @@ -392,33 +361,25 @@ class SearchTests(InvenTreeAPITestCase): 'salesorder', ] - query = { - 'search': 'c', - 'limit': 3, - } + query = {'search': 'c', 'limit': 3} for mdl in models: query[mdl] = {} - response = self.post( - reverse('api-search'), - query, - expected_code=200 - ) + response = self.post(reverse('api-search'), query, expected_code=200) # Check for 'permission denied' error for mdl in models: - self.assertEqual(response.data[mdl]['error'], 'User does not have permission to view this model') + self.assertEqual( + response.data[mdl]['error'], + 'User does not have permission to view this model', + ) # Assign view roles for some parts self.assignRole('build.view') self.assignRole('part.view') - response = self.post( - reverse('api-search'), - query, - expected_code=200 - ) + response = self.post(reverse('api-search'), query, expected_code=200) # Check for expected results, based on permissions # We expect results to be returned for the following model types @@ -438,4 +399,6 @@ class SearchTests(InvenTreeAPITestCase): self.assertIn('count', result) else: self.assertIn('error', result) - self.assertEqual(result['error'], 'User does not have permission to view this model') + self.assertEqual( + result['error'], 'User does not have permission to view this model' + ) diff --git a/InvenTree/InvenTree/test_api_version.py b/InvenTree/InvenTree/test_api_version.py index d6a2c38fb6..4b2a3b49b5 100644 --- a/InvenTree/InvenTree/test_api_version.py +++ b/InvenTree/InvenTree/test_api_version.py @@ -1,6 +1,5 @@ """Tests for api_version.""" - from django.urls import reverse from InvenTree.api_version import INVENTREE_API_VERSION diff --git a/InvenTree/InvenTree/test_middleware.py b/InvenTree/InvenTree/test_middleware.py index f6928a798b..f750a339b2 100644 --- a/InvenTree/InvenTree/test_middleware.py +++ b/InvenTree/InvenTree/test_middleware.py @@ -66,6 +66,7 @@ class MiddlewareTests(InvenTreeTestCase): def test_error_exceptions(self): """Test that ignored errors are not logged.""" + def check(excpected_nbr=0): # Check that errors are empty errors = Error.objects.all() diff --git a/InvenTree/InvenTree/test_tasks.py b/InvenTree/InvenTree/test_tasks.py index 3dacc41cca..ad7f058de5 100644 --- a/InvenTree/InvenTree/test_tasks.py +++ b/InvenTree/InvenTree/test_tasks.py @@ -67,15 +67,23 @@ class InvenTreeTaskTests(TestCase): # Error runs # Malformed taskname - with self.assertWarnsMessage(UserWarning, "WARNING: 'InvenTree' not started - Malformed function path"): + with self.assertWarnsMessage( + UserWarning, "WARNING: 'InvenTree' not started - Malformed function path" + ): InvenTree.tasks.offload_task('InvenTree') # Non existent app - with self.assertWarnsMessage(UserWarning, "WARNING: 'InvenTreeABC.test_tasks.doesnotmatter' not started - No module named 'InvenTreeABC.test_tasks'"): + with self.assertWarnsMessage( + UserWarning, + "WARNING: 'InvenTreeABC.test_tasks.doesnotmatter' not started - No module named 'InvenTreeABC.test_tasks'", + ): InvenTree.tasks.offload_task('InvenTreeABC.test_tasks.doesnotmatter') # Non existent function - with self.assertWarnsMessage(UserWarning, "WARNING: 'InvenTree.test_tasks.doesnotexsist' not started - No function named 'doesnotexsist'"): + with self.assertWarnsMessage( + UserWarning, + "WARNING: 'InvenTree.test_tasks.doesnotexsist' not started - No function named 'doesnotexsist'", + ): InvenTree.tasks.offload_task('InvenTree.test_tasks.doesnotexsist') def test_task_hearbeat(self): @@ -86,7 +94,9 @@ class InvenTreeTaskTests(TestCase): """Test the task delete_successful_tasks.""" from django_q.models import Success - Success.objects.create(name='abc', func='abc', stopped=threshold, started=threshold_low) + Success.objects.create( + name='abc', func='abc', stopped=threshold, started=threshold_low + ) InvenTree.tasks.offload_task(InvenTree.tasks.delete_successful_tasks) results = Success.objects.filter(started__lte=threshold) self.assertEqual(len(results), 0) @@ -99,14 +109,14 @@ class InvenTreeTaskTests(TestCase): error_obj.save() # Check that it is not empty - errors = Error.objects.filter(when__lte=threshold,) + errors = Error.objects.filter(when__lte=threshold) self.assertNotEqual(len(errors), 0) # Run action InvenTree.tasks.offload_task(InvenTree.tasks.delete_old_error_logs) # Check that it is empty again - errors = Error.objects.filter(when__lte=threshold,) + errors = Error.objects.filter(when__lte=threshold) self.assertEqual(len(errors), 0) def test_task_check_for_updates(self): @@ -146,7 +156,9 @@ class InvenTreeTaskTests(TestCase): # Cleanup try: migration_name = InvenTree.tasks.get_migration_plan()[0][0].name + '.py' - migration_path = settings.BASE_DIR / 'InvenTree' / 'migrations' / migration_name + migration_path = ( + settings.BASE_DIR / 'InvenTree' / 'migrations' / migration_name + ) migration_path.unlink() except IndexError: # pragma: no cover pass diff --git a/InvenTree/InvenTree/test_urls.py b/InvenTree/InvenTree/test_urls.py index 2f2db7e2a9..6229d39a81 100644 --- a/InvenTree/InvenTree/test_urls.py +++ b/InvenTree/InvenTree/test_urls.py @@ -44,7 +44,7 @@ class URLTest(TestCase): ('part', 'templates'), ('report', 'templates'), ('stock', 'templates'), - ('templates', ), + ('templates',), ] template_files = [] @@ -53,11 +53,9 @@ class URLTest(TestCase): tld = os.path.join(here, '..') for directory in template_dirs: - template_dir = os.path.join(tld, *directory) for path in Path(template_dir).rglob(suffix): - f = os.path.abspath(path) if f not in template_files: @@ -72,22 +70,15 @@ class URLTest(TestCase): pattern = "{% url ['\"]([^'\"]+)['\"]([^%]*)%}" with open(input_file, 'r') as f: - data = f.read() results = re.findall(pattern, data) for result in results: if len(result) == 2: - urls.append([ - result[0].strip(), - result[1].strip() - ]) + urls.append([result[0].strip(), result[1].strip()]) elif len(result) == 1: # pragma: no cover - urls.append([ - result[0].strip(), - '' - ]) + urls.append([result[0].strip(), '']) return urls diff --git a/InvenTree/InvenTree/test_views.py b/InvenTree/InvenTree/test_views.py index 708304c68d..6defdd9338 100644 --- a/InvenTree/InvenTree/test_views.py +++ b/InvenTree/InvenTree/test_views.py @@ -60,25 +60,11 @@ class ViewTests(InvenTreeTestCase): self.assertEqual(response.status_code, 200) content = response.content.decode() - user_panels = [ - 'account', - 'user-display', - 'user-home', - 'user-reports', - ] + user_panels = ['account', 'user-display', 'user-home', 'user-reports'] - staff_panels = [ - 'server', - 'login', - 'barcodes', - 'pricing', - 'parts', - 'stock', - ] + staff_panels = ['server', 'login', 'barcodes', 'pricing', 'parts', 'stock'] - plugin_panels = [ - 'plugin', - ] + plugin_panels = ['plugin'] # Default user has staff access, so all panels will be present for panel in user_panels + staff_panels + plugin_panels: @@ -87,8 +73,7 @@ class ViewTests(InvenTreeTestCase): # Now create a user who does not have staff access pleb_user = get_user_model().objects.create_user( - username='pleb', - password='notstaff', + username='pleb', password='notstaff' ) pleb_user.groups.add(self.group) @@ -98,10 +83,7 @@ class ViewTests(InvenTreeTestCase): self.client.logout() - result = self.client.login( - username='pleb', - password='notstaff', - ) + result = self.client.login(username='pleb', password='notstaff') self.assertTrue(result) @@ -127,6 +109,8 @@ class ViewTests(InvenTreeTestCase): self.assertEqual(response.status_code, 302) # Try login with url - response = self.client.get(f"/accounts/login/?next=/&login={self.username}&password={self.password}") + response = self.client.get( + f"/accounts/login/?next=/&login={self.username}&password={self.password}" + ) self.assertEqual(response.status_code, 302) self.assertEqual(response.url, '/') diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 5c7fe65c02..833fd55b4a 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -104,13 +104,7 @@ class ConversionTest(TestCase): def test_invalid_units(self): """Test conversion with bad units""" - tests = { - '3': '10', - '13': '-?-', - '-3': 'xyz', - '-12': '-12', - '1/0': '1/0', - } + tests = {'3': '10', '13': '-?-', '-3': 'xyz', '-12': '-12', '1/0': '1/0'} for val, unit in tests.items(): with self.assertRaises(ValidationError): @@ -118,12 +112,7 @@ class ConversionTest(TestCase): def test_invalid_values(self): """Test conversion of invalid inputs""" - inputs = [ - '-x', - '1/0', - 'xyz', - '12B45C' - ] + inputs = ['-x', '1/0', 'xyz', '12B45C'] for val in inputs: # Test with a provided unit @@ -148,9 +137,7 @@ class ConversionTest(TestCase): # Create a new custom unit CustomUnit.objects.create( - name='fanciful_unit', - definition='henry / mm', - symbol='hpmm', + name='fanciful_unit', definition='henry / mm', symbol='hpmm' ) # Reload registry @@ -171,7 +158,9 @@ class ConversionTest(TestCase): for val, expected in tests.items(): # Convert, and leave units - q = InvenTree.conversion.convert_physical_value(val, 'henry / km', strip_units=False) + q = InvenTree.conversion.convert_physical_value( + val, 'henry / km', strip_units=False + ) self.assertAlmostEqual(float(q.magnitude), expected, 2) # Convert and strip units @@ -250,12 +239,7 @@ class FormatTest(TestCase): self.assertIn('date', info) # Try with invalid strings - for fmt in [ - 'PO-{{xyz}', - 'PO-{xyz}}', - 'PO-{xyz}-{', - ]: - + for fmt in ['PO-{{xyz}', 'PO-{xyz}}', 'PO-{xyz}-{']: with self.assertRaises(ValueError): InvenTree.format.parse_format_string(fmt) @@ -278,7 +262,7 @@ class FormatTest(TestCase): "ABC-hello-123": "???-{q}-###", "BO-1234": "BO-{ref}", "111.222.fred.china": "???.###.{name}.{place}", - "PO-1234": "PO-{ref:04d}" + "PO-1234": "PO-{ref:04d}", }.items(): self.assertTrue(InvenTree.format.validate_string(value, pattern)) @@ -287,7 +271,7 @@ class FormatTest(TestCase): "ABC-hello-123": "###-{q}-???", "BO-1234": "BO.{ref}", "BO-####": "BO-{pattern}-{next}", - "BO-123d": "BO-{ref:04d}" + "BO-123d": "BO-{ref:04d}", }.items(): self.assertFalse(InvenTree.format.validate_string(value, pattern)) @@ -296,20 +280,13 @@ class FormatTest(TestCase): # Simple tests based on a straight-forward format string fmt = "PO-###-{ref:04d}" - tests = { - "123": "PO-123-123", - "456": "PO-123-456", - "789": "PO-123-789", - } + tests = {"123": "PO-123-123", "456": "PO-123-456", "789": "PO-123-789"} for k, v in tests.items(): self.assertEqual(InvenTree.format.extract_named_group('ref', v, fmt), k) # However these ones should fail - tests = { - 'abc': 'PO-123-abc', - 'xyz': 'PO-123-xyz', - } + tests = {'abc': 'PO-123-abc', 'xyz': 'PO-123-xyz'} for v in tests.values(): with self.assertRaises(ValueError): @@ -319,11 +296,7 @@ class FormatTest(TestCase): fmt = "PO-{date}-{test}-???-{ref}-###" val = "PO-2022-02-01-hello-ABC-12345-222" - data = { - 'date': '2022-02-01', - 'test': 'hello', - 'ref': '12345', - } + data = {'date': '2022-02-01', 'test': 'hello', 'ref': '12345'} for k, v in data.items(): self.assertEqual(InvenTree.format.extract_named_group(k, val, fmt), v) @@ -332,60 +305,46 @@ class FormatTest(TestCase): # Raises a ValueError as the format string is bad with self.assertRaises(ValueError): - InvenTree.format.extract_named_group( - "test", - "PO-1234-5", - "PO-{test}-{" - ) + InvenTree.format.extract_named_group("test", "PO-1234-5", "PO-{test}-{") # Raises a NameError as the named group does not exist in the format string with self.assertRaises(NameError): - InvenTree.format.extract_named_group( - "missing", - "PO-12345", - "PO-{test}", - ) + InvenTree.format.extract_named_group("missing", "PO-12345", "PO-{test}") # Raises a ValueError as the value does not match the format string with self.assertRaises(ValueError): - InvenTree.format.extract_named_group( - "test", - "PO-1234", - "PO-{test}-1234", - ) + InvenTree.format.extract_named_group("test", "PO-1234", "PO-{test}-1234") with self.assertRaises(ValueError): - InvenTree.format.extract_named_group( - "test", - "PO-ABC-xyz", - "PO-###-{test}", - ) + InvenTree.format.extract_named_group("test", "PO-ABC-xyz", "PO-###-{test}") def test_currency_formatting(self): """Test that currency formatting works correctly for multiple currencies""" test_data = ( - (Money( 3651.285718, "USD"), 4, "$3,651.2857" ), # noqa: E201,E202 + (Money(3651.285718, "USD"), 4, "$3,651.2857"), # noqa: E201,E202 (Money(487587.849178, "CAD"), 5, "CA$487,587.84918"), # noqa: E201,E202 - (Money( 0.348102, "EUR"), 1, "€0.3" ), # noqa: E201,E202 - (Money( 0.916530, "GBP"), 1, "£0.9" ), # noqa: E201,E202 - (Money( 61.031024, "JPY"), 3, "¥61.031" ), # noqa: E201,E202 - (Money( 49609.694602, "JPY"), 1, "¥49,609.7" ), # noqa: E201,E202 - (Money(155565.264777, "AUD"), 2, "A$155,565.26" ), # noqa: E201,E202 - (Money( 0.820437, "CNY"), 4, "CN¥0.8204" ), # noqa: E201,E202 - (Money( 7587.849178, "EUR"), 0, "€7,588" ), # noqa: E201,E202 - (Money( 0.348102, "GBP"), 3, "£0.348" ), # noqa: E201,E202 - (Money( 0.652923, "CHF"), 0, "CHF1" ), # noqa: E201,E202 - (Money( 0.820437, "CNY"), 1, "CN¥0.8" ), # noqa: E201,E202 - (Money(98789.5295680, "CHF"), 0, "CHF98,790" ), # noqa: E201,E202 - (Money( 0.585787, "USD"), 1, "$0.6" ), # noqa: E201,E202 - (Money( 0.690541, "CAD"), 3, "CA$0.691" ), # noqa: E201,E202 - (Money( 427.814104, "AUD"), 5, "A$427.81410" ), # noqa: E201,E202 + (Money(0.348102, "EUR"), 1, "€0.3"), # noqa: E201,E202 + (Money(0.916530, "GBP"), 1, "£0.9"), # noqa: E201,E202 + (Money(61.031024, "JPY"), 3, "¥61.031"), # noqa: E201,E202 + (Money(49609.694602, "JPY"), 1, "¥49,609.7"), # noqa: E201,E202 + (Money(155565.264777, "AUD"), 2, "A$155,565.26"), # noqa: E201,E202 + (Money(0.820437, "CNY"), 4, "CN¥0.8204"), # noqa: E201,E202 + (Money(7587.849178, "EUR"), 0, "€7,588"), # noqa: E201,E202 + (Money(0.348102, "GBP"), 3, "£0.348"), # noqa: E201,E202 + (Money(0.652923, "CHF"), 0, "CHF1"), # noqa: E201,E202 + (Money(0.820437, "CNY"), 1, "CN¥0.8"), # noqa: E201,E202 + (Money(98789.5295680, "CHF"), 0, "CHF98,790"), # noqa: E201,E202 + (Money(0.585787, "USD"), 1, "$0.6"), # noqa: E201,E202 + (Money(0.690541, "CAD"), 3, "CA$0.691"), # noqa: E201,E202 + (Money(427.814104, "AUD"), 5, "A$427.81410"), # noqa: E201,E202 ) with self.settings(LANGUAGE_CODE="en-us"): for value, decimal_places, expected_result in test_data: - result = InvenTree.format.format_money(value, decimal_places=decimal_places) + result = InvenTree.format.format_money( + value, decimal_places=decimal_places + ) assert result == expected_result @@ -414,10 +373,15 @@ class TestHelpers(TestCase): for url, expected in tests.items(): # Test with supplied base URL - self.assertEqual(InvenTree.helpers_model.construct_absolute_url(url, site_url=base), expected) + self.assertEqual( + InvenTree.helpers_model.construct_absolute_url(url, site_url=base), + expected, + ) # Test without supplied base URL - self.assertEqual(InvenTree.helpers_model.construct_absolute_url(url), expected) + self.assertEqual( + InvenTree.helpers_model.construct_absolute_url(url), expected + ) def test_image_url(self): """Test if a filename looks like an image.""" @@ -453,7 +417,9 @@ class TestHelpers(TestCase): """Test static url helpers.""" self.assertEqual(helpers.getStaticUrl('test.jpg'), '/static/test.jpg') self.assertEqual(helpers.getBlankImage(), '/static/img/blank_image.png') - self.assertEqual(helpers.getBlankThumbnail(), '/static/img/blank_image.thumbnail.png') + self.assertEqual( + helpers.getBlankThumbnail(), '/static/img/blank_image.thumbnail.png' + ) def testMediaUrl(self): """Test getMediaUrl.""" @@ -476,12 +442,7 @@ class TestHelpers(TestCase): def test_download_image(self): """Test function for downloading image from remote URL""" # Run check with a sequence of bad URLs - for url in [ - "blog", - "htp://test.com/?", - "google", - "\\invalid-url" - ]: + for url in ["blog", "htp://test.com/?", "google", "\\invalid-url"]: with self.assertRaises(django_exceptions.ValidationError): InvenTree.helpers_model.download_image_from_url(url) @@ -496,9 +457,10 @@ class TestHelpers(TestCase): with self.assertRaises(expected_error): while tries < retries: - try: - InvenTree.helpers_model.download_image_from_url(url, timeout=timeout) + InvenTree.helpers_model.download_image_from_url( + url, timeout=timeout + ) break except Exception as exc: if type(exc) is expected_error: @@ -520,14 +482,18 @@ class TestHelpers(TestCase): large_img = "https://github.com/inventree/InvenTree/raw/master/InvenTree/InvenTree/static/img/paper_splash_large.jpg" - InvenTreeSetting.set_setting('INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE', 1, change_user=None) + InvenTreeSetting.set_setting( + 'INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE', 1, change_user=None + ) # Attempt to download an image which is too large with self.assertRaises(ValueError): InvenTree.helpers_model.download_image_from_url(large_img, timeout=10) # Increase allowable download size - InvenTreeSetting.set_setting('INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE', 5, change_user=None) + InvenTreeSetting.set_setting( + 'INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE', 5, change_user=None + ) # Download a valid image (should not throw an error) InvenTree.helpers_model.download_image_from_url(large_img, timeout=10) @@ -584,13 +550,7 @@ class TestMakeBarcode(TestCase): def test_barcode_extended(self): """Test creation of barcode with extended data.""" bc = helpers.MakeBarcode( - "part", - 3, - { - "id": 3, - "url": "www.google.com", - }, - brief=False + "part", 3, {"id": 3, "url": "www.google.com"}, brief=False ) self.assertIn('part', bc) @@ -604,10 +564,7 @@ class TestMakeBarcode(TestCase): def test_barcode_brief(self): """Test creation of simple barcode.""" - bc = helpers.MakeBarcode( - "stockitem", - 7, - ) + bc = helpers.MakeBarcode("stockitem", 7) data = json.loads(bc) self.assertEqual(len(data), 1) @@ -626,9 +583,7 @@ class TestDownloadFile(TestCase): class TestMPTT(TestCase): """Tests for the MPTT tree models.""" - fixtures = [ - 'location', - ] + fixtures = ['location'] @classmethod def setUpTestData(cls): @@ -848,7 +803,7 @@ class TestVersionNumber(TestCase): """Test that the git commit information is extracted successfully.""" envs = { 'INVENTREE_COMMIT_HASH': 'abcdef', - 'INVENTREE_COMMIT_DATE': '2022-12-31' + 'INVENTREE_COMMIT_DATE': '2022-12-31', } # Check that the environment variables take priority @@ -861,10 +816,16 @@ class TestVersionNumber(TestCase): # Check that the current .git values work too - hash = str(subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8').strip() + hash = str( + subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8' + ).strip() self.assertEqual(hash, version.inventreeCommitHash()) - d = str(subprocess.check_output('git show -s --format=%ci'.split()), 'utf-8').strip().split(' ')[0] + d = ( + str(subprocess.check_output('git show -s --format=%ci'.split()), 'utf-8') + .strip() + .split(' ')[0] + ) self.assertEqual(d, version.inventreeCommitDate()) @@ -975,16 +936,14 @@ class TestSettings(InvenTreeTestCase): self.assertEqual(user_count(), 1) # not enough set - self.run_reload({ - 'INVENTREE_ADMIN_USER': 'admin' - }) + self.run_reload({'INVENTREE_ADMIN_USER': 'admin'}) self.assertEqual(user_count(), 1) # enough set self.run_reload({ 'INVENTREE_ADMIN_USER': 'admin', # set username 'INVENTREE_ADMIN_EMAIL': 'info@example.com', # set email - 'INVENTREE_ADMIN_PASSWORD': 'password123' # set password + 'INVENTREE_ADMIN_PASSWORD': 'password123', # set password }) self.assertEqual(user_count(), 2) @@ -1027,27 +986,27 @@ class TestSettings(InvenTreeTestCase): """Test get_config_file.""" # normal run - not configured - valid = [ - 'inventree/config.yaml', - 'inventree/data/config.yaml', - ] + valid = ['inventree/config.yaml', 'inventree/data/config.yaml'] - self.assertTrue(any(opt in str(config.get_config_file()).lower() for opt in valid)) + self.assertTrue( + any(opt in str(config.get_config_file()).lower() for opt in valid) + ) # with env set with self.in_env_context({'INVENTREE_CONFIG_FILE': 'my_special_conf.yaml'}): - self.assertIn('inventree/my_special_conf.yaml', str(config.get_config_file()).lower()) + self.assertIn( + 'inventree/my_special_conf.yaml', str(config.get_config_file()).lower() + ) def test_helpers_plugin_file(self): """Test get_plugin_file.""" # normal run - not configured - valid = [ - 'inventree/plugins.txt', - 'inventree/data/plugins.txt', - ] + valid = ['inventree/plugins.txt', 'inventree/data/plugins.txt'] - self.assertTrue(any(opt in str(config.get_plugin_file()).lower() for opt in valid)) + self.assertTrue( + any(opt in str(config.get_plugin_file()).lower() for opt in valid) + ) # with env set with self.in_env_context({'INVENTREE_PLUGIN_FILE': 'my_special_plugins.txt'}): @@ -1064,11 +1023,15 @@ class TestSettings(InvenTreeTestCase): self.assertEqual(config.get_setting(TEST_ENV_NAME, None), '321') # test typecasting to dict - None should be mapped to empty dict - self.assertEqual(config.get_setting(TEST_ENV_NAME, None, None, typecast=dict), {}) + self.assertEqual( + config.get_setting(TEST_ENV_NAME, None, None, typecast=dict), {} + ) # test typecasting to dict - valid JSON string should be mapped to corresponding dict with self.in_env_context({TEST_ENV_NAME: '{"a": 1}'}): - self.assertEqual(config.get_setting(TEST_ENV_NAME, None, typecast=dict), {"a": 1}) + self.assertEqual( + config.get_setting(TEST_ENV_NAME, None, typecast=dict), {"a": 1} + ) # test typecasting to dict - invalid JSON string should be mapped to empty dict with self.in_env_context({TEST_ENV_NAME: "{'a': 1}"}): @@ -1096,7 +1059,9 @@ class TestInstanceName(InvenTreeTestCase): def test_instance_url(self): """Test instance url settings.""" # Set up required setting - InvenTreeSetting.set_setting("INVENTREE_BASE_URL", "http://127.1.2.3", self.user) + InvenTreeSetting.set_setting( + "INVENTREE_BASE_URL", "http://127.1.2.3", self.user + ) # The site should also be changed site_obj = Site.objects.all().order_by('id').first() @@ -1106,12 +1071,7 @@ class TestInstanceName(InvenTreeTestCase): class TestOffloadTask(InvenTreeTestCase): """Tests for offloading tasks to the background worker""" - fixtures = [ - 'category', - 'part', - 'location', - 'stock', - ] + fixtures = ['category', 'part', 'location', 'stock'] def test_offload_tasks(self): """Test that we can offload various tasks to the background worker thread. @@ -1127,38 +1087,38 @@ class TestOffloadTask(InvenTreeTestCase): Ref: https://github.com/inventree/InvenTree/pull/3273 """ - self.assertTrue(offload_task( - 'dummy_tasks.stock', - item=StockItem.objects.get(pk=1), - loc=StockLocation.objects.get(pk=1), - force_async=True - )) + self.assertTrue( + offload_task( + 'dummy_tasks.stock', + item=StockItem.objects.get(pk=1), + loc=StockLocation.objects.get(pk=1), + force_async=True, + ) + ) - self.assertTrue(offload_task( - 'dummy_task.numbers', - 1, 2, 3, 4, 5, - force_async=True - )) + self.assertTrue( + offload_task('dummy_task.numbers', 1, 2, 3, 4, 5, force_async=True) + ) # Offload a dummy task, but force sync # This should fail, because the function does not exist with self.assertLogs(logger='inventree', level='WARNING') as log: - self.assertFalse(offload_task( - 'dummy_task.numbers', - 1, 1, 1, - force_sync=True - )) + self.assertFalse( + offload_task('dummy_task.numbers', 1, 1, 1, force_sync=True) + ) self.assertIn("Malformed function path", str(log.output)) # Offload dummy task with a Part instance # This should succeed, ensuring that the Part instance is correctly pickled - self.assertTrue(offload_task( - 'dummy_tasks.parts', - part=Part.objects.get(pk=1), - cat=PartCategory.objects.get(pk=1), - force_async=True - )) + self.assertTrue( + offload_task( + 'dummy_tasks.parts', + part=Part.objects.get(pk=1), + cat=PartCategory.objects.get(pk=1), + force_async=True, + ) + ) def test_daily_holdoff(self): """Tests for daily task holdoff helper functions""" @@ -1180,7 +1140,9 @@ class TestOffloadTask(InvenTreeTestCase): # An attempt has been logged, but it is too recent result = InvenTree.tasks.check_daily_holdoff('dummy_task') self.assertFalse(result) - self.assertIn("Last attempt for 'dummy_task' was too recent", str(cm.output)) + self.assertIn( + "Last attempt for 'dummy_task' was too recent", str(cm.output) + ) # Mark last attempt a few days ago - should now return True t_old = datetime.now() - timedelta(days=3) @@ -1191,13 +1153,17 @@ class TestOffloadTask(InvenTreeTestCase): self.assertTrue(result) # Last attempt should have been updated - self.assertNotEqual(t_old, InvenTreeSetting.get_setting('_dummy_task_ATTEMPT', '', cache=False)) + self.assertNotEqual( + t_old, InvenTreeSetting.get_setting('_dummy_task_ATTEMPT', '', cache=False) + ) # Last attempt should prevent us now with self.assertLogs(logger='inventree', level='INFO') as cm: result = InvenTree.tasks.check_daily_holdoff('dummy_task') self.assertFalse(result) - self.assertIn("Last attempt for 'dummy_task' was too recent", str(cm.output)) + self.assertIn( + "Last attempt for 'dummy_task' was too recent", str(cm.output) + ) # Configure so a task was successful too recently InvenTreeSetting.set_setting('_dummy_task_ATTEMPT', t_old, None) diff --git a/InvenTree/InvenTree/translation.py b/InvenTree/InvenTree/translation.py index d07f5c8ed7..3615a6d05e 100644 --- a/InvenTree/InvenTree/translation.py +++ b/InvenTree/InvenTree/translation.py @@ -24,7 +24,7 @@ def reload_translation_stats(): keys = _translation_stats.keys() # Note that the names used in the stats file may not align 100% - for (code, _lang) in settings.LANGUAGES: + for code, _lang in settings.LANGUAGES: if code in keys: # Direct match, move on continue diff --git a/InvenTree/InvenTree/unit_test.py b/InvenTree/InvenTree/unit_test.py index 43a2d66cbf..52e84bb589 100644 --- a/InvenTree/InvenTree/unit_test.py +++ b/InvenTree/InvenTree/unit_test.py @@ -56,7 +56,6 @@ def getOldestMigrationFile(app, exclude_extension=True, ignore_initial=True): oldest_file = None for f in getMigrationFileNames(app): - if ignore_initial and f.startswith('0001_initial'): continue @@ -115,9 +114,7 @@ class UserMixin: # Create a user to log in with cls.user = get_user_model().objects.create_user( - username=cls.username, - password=cls.password, - email=cls.email + username=cls.username, password=cls.password, email=cls.email ) # Create a group for the user @@ -163,15 +160,15 @@ class UserMixin: raise TypeError('assignRole: assign_all must be a boolean value') if not role and not assign_all: - raise ValueError('assignRole: either role must be provided, or assign_all must be set') + raise ValueError( + 'assignRole: either role must be provided, or assign_all must be set' + ) if not assign_all and role: rule, perm = role.split('.') for ruleset in group.rule_sets.all(): - if assign_all or ruleset.name == rule: - if assign_all or perm == 'view': ruleset.can_view = True elif assign_all or perm == 'change': @@ -205,37 +202,24 @@ class ExchangeRateMixin: def generate_exchange_rates(self): """Helper function which generates some exchange rates to work with""" - rates = { - 'AUD': 1.5, - 'CAD': 1.7, - 'GBP': 0.9, - 'USD': 1.0, - } + rates = {'AUD': 1.5, 'CAD': 1.7, 'GBP': 0.9, 'USD': 1.0} # Create a dummy backend - ExchangeBackend.objects.create( - name='InvenTreeExchange', - base_currency='USD', - ) + ExchangeBackend.objects.create(name='InvenTreeExchange', base_currency='USD') backend = ExchangeBackend.objects.get(name='InvenTreeExchange') items = [] for currency, rate in rates.items(): - items.append( - Rate( - currency=currency, - value=rate, - backend=backend, - ) - ) + items.append(Rate(currency=currency, value=rate, backend=backend)) Rate.objects.bulk_create(items) class InvenTreeTestCase(ExchangeRateMixin, UserMixin, TestCase): """Testcase with user setup buildin.""" + pass @@ -243,7 +227,9 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase): """Base class for running InvenTree API tests.""" @contextmanager - def assertNumQueriesLessThan(self, value, using='default', verbose=False, debug=False): + def assertNumQueriesLessThan( + self, value, using='default', verbose=False, debug=False + ): """Context manager to check that the number of queries is less than a certain value. Example: @@ -252,7 +238,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase): Ref: https://stackoverflow.com/questions/1254170/django-is-there-a-way-to-count-sql-queries-from-an-unit-test/59089020#59089020 """ with CaptureQueriesContext(connections[using]) as context: - yield # your test will be run here + yield # your test will be run here if verbose: msg = "\r\n%s" % json.dumps(context.captured_queries, indent=4) @@ -273,8 +259,9 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase): return if expected_code != response.status_code: - - print(f"Unexpected {method} response at '{url}': status_code = {response.status_code}") + print( + f"Unexpected {method} response at '{url}': status_code = {response.status_code}" + ) if hasattr(response, 'data'): print('data:', response.data) @@ -359,7 +346,9 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase): return response - def download_file(self, url, data, expected_code=None, expected_fn=None, decode=True): + def download_file( + self, url, data, expected_code=None, expected_fn=None, decode=True + ): """Download a file from the server, and return an in-memory file.""" response = self.client.get(url, data=data, format='json') @@ -367,7 +356,9 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase): # Check that the response is of the correct type if not isinstance(response, StreamingHttpResponse): - raise ValueError("Response is not a StreamingHttpResponse object as expected") + raise ValueError( + "Response is not a StreamingHttpResponse object as expected" + ) # Extract filename disposition = response.headers['Content-Disposition'] @@ -394,7 +385,14 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase): return file - def process_csv(self, file_object, delimiter=',', required_cols=None, excluded_cols=None, required_rows=None): + def process_csv( + self, + file_object, + delimiter=',', + required_cols=None, + excluded_cols=None, + required_rows=None, + ): """Helper function to process and validate a downloaded csv file.""" # Check that the correct object type has been passed self.assertTrue(isinstance(file_object, io.StringIO)) diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index 3a9a00d214..3f5f5179a5 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -10,9 +10,11 @@ from django.urls import include, path, re_path from django.views.decorators.csrf import csrf_exempt from django.views.generic.base import RedirectView -from dj_rest_auth.registration.views import (ConfirmEmailView, - SocialAccountDisconnectView, - SocialAccountListView) +from dj_rest_auth.registration.views import ( + ConfirmEmailView, + SocialAccountDisconnectView, + SocialAccountListView, +) from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView from sesame.views import LoginView @@ -28,36 +30,49 @@ import stock.api import users.api from build.urls import build_urls from common.urls import common_urls -from company.urls import (company_urls, manufacturer_part_urls, - supplier_part_urls) +from company.urls import company_urls, manufacturer_part_urls, supplier_part_urls from order.urls import order_urls from part.urls import part_urls from plugin.urls import get_plugin_urls from stock.urls import stock_urls from web.urls import urlpatterns as platform_urls -from .api import (APISearchView, InfoView, NotFoundView, VersionTextView, - VersionView) +from .api import APISearchView, InfoView, NotFoundView, VersionTextView, VersionView from .magic_login import GetSimpleLoginView -from .social_auth_urls import (EmailListView, EmailPrimaryView, - EmailRemoveView, EmailVerifyView, - SocialProviderListView, social_auth_urlpatterns) -from .views import (AboutView, AppearanceSelectView, CustomConnectionsView, - CustomEmailView, CustomLoginView, - CustomPasswordResetFromKeyView, - CustomSessionDeleteOtherView, CustomSessionDeleteView, - DatabaseStatsView, DynamicJsView, EditUserView, IndexView, - NotificationsView, SearchView, SetPasswordView, - SettingsView, auth_request) +from .social_auth_urls import ( + EmailListView, + EmailPrimaryView, + EmailRemoveView, + EmailVerifyView, + SocialProviderListView, + social_auth_urlpatterns, +) +from .views import ( + AboutView, + AppearanceSelectView, + CustomConnectionsView, + CustomEmailView, + CustomLoginView, + CustomPasswordResetFromKeyView, + CustomSessionDeleteOtherView, + CustomSessionDeleteView, + DatabaseStatsView, + DynamicJsView, + EditUserView, + IndexView, + NotificationsView, + SearchView, + SetPasswordView, + SettingsView, + auth_request, +) admin.site.site_header = "InvenTree Admin" apipatterns = [ - # Global search path('search/', APISearchView.as_view(), name='api-search'), - re_path(r'^settings/', include(common.api.settings_api_urls)), re_path(r'^part/', include(part.api.part_api_urls)), re_path(r'^bom/', include(part.api.bom_api_urls)), @@ -69,117 +84,288 @@ apipatterns = [ re_path(r'^report/', include(report.api.report_api_urls)), re_path(r'^user/', include(users.api.user_urls)), re_path(r'^admin/', include(common.api.admin_api_urls)), - # Plugin endpoints path('', include(plugin.api.plugin_api_urls)), - # Common endpoints endpoint path('', include(common.api.common_api_urls)), - # OpenAPI Schema - re_path('schema/', SpectacularAPIView.as_view(custom_settings={'SCHEMA_PATH_PREFIX': '/api/'}), name='schema'), - + re_path( + 'schema/', + SpectacularAPIView.as_view(custom_settings={'SCHEMA_PATH_PREFIX': '/api/'}), + name='schema', + ), # InvenTree information endpoints - path("version-text", VersionTextView.as_view(), name="api-version-text"), # version text + path( + "version-text", VersionTextView.as_view(), name="api-version-text" + ), # version text path('version/', VersionView.as_view(), name='api-version'), # version info path('', InfoView.as_view(), name='api-inventree-info'), # server info - # Auth API endpoints - path('auth/', include([ - re_path(r'^registration/account-confirm-email/(?P[-:\w]+)/$', ConfirmEmailView.as_view(), name='account_confirm_email'), - path('registration/', include('dj_rest_auth.registration.urls')), - path('providers/', SocialProviderListView.as_view(), name='social_providers'), - path('emails/', include([path('/', include([ - path('primary/', EmailPrimaryView.as_view(), name='email-primary'), - path('verify/', EmailVerifyView.as_view(), name='email-verify'), - path('remove/', EmailRemoveView().as_view(), name='email-remove'),])), - path('', EmailListView.as_view(), name='email-list') - ])), - path('social/', include(social_auth_urlpatterns)), - path('social/', SocialAccountListView.as_view(), name='social_account_list'), - path('social//disconnect/', SocialAccountDisconnectView.as_view(), name='social_account_disconnect'), - path('', include('dj_rest_auth.urls')), - ])), - + path( + 'auth/', + include([ + re_path( + r'^registration/account-confirm-email/(?P[-:\w]+)/$', + ConfirmEmailView.as_view(), + name='account_confirm_email', + ), + path('registration/', include('dj_rest_auth.registration.urls')), + path( + 'providers/', SocialProviderListView.as_view(), name='social_providers' + ), + path( + 'emails/', + include([ + path( + '/', + include([ + path( + 'primary/', + EmailPrimaryView.as_view(), + name='email-primary', + ), + path( + 'verify/', + EmailVerifyView.as_view(), + name='email-verify', + ), + path( + 'remove/', + EmailRemoveView().as_view(), + name='email-remove', + ), + ]), + ), + path('', EmailListView.as_view(), name='email-list'), + ]), + ), + path('social/', include(social_auth_urlpatterns)), + path( + 'social/', SocialAccountListView.as_view(), name='social_account_list' + ), + path( + 'social//disconnect/', + SocialAccountDisconnectView.as_view(), + name='social_account_disconnect', + ), + path('', include('dj_rest_auth.urls')), + ]), + ), # Magic login URLs - path("email/generate/", csrf_exempt(GetSimpleLoginView().as_view()), name="sesame-generate",), + path( + "email/generate/", + csrf_exempt(GetSimpleLoginView().as_view()), + name="sesame-generate", + ), path("email/login/", LoginView.as_view(), name="sesame-login"), - # Unknown endpoint re_path(r'^.*$', NotFoundView.as_view(), name='api-404'), ] settings_urls = [ - re_path(r'^i18n/?', include('django.conf.urls.i18n')), - - re_path(r'^appearance/?', AppearanceSelectView.as_view(), name='settings-appearance'), - + re_path( + r'^appearance/?', AppearanceSelectView.as_view(), name='settings-appearance' + ), # Catch any other urls - re_path(r'^.*$', SettingsView.as_view(template_name='InvenTree/settings/settings.html'), name='settings'), + re_path( + r'^.*$', + SettingsView.as_view(template_name='InvenTree/settings/settings.html'), + name='settings', + ), ] notifications_urls = [ - # Catch any other urls - re_path(r'^.*$', NotificationsView.as_view(), name='notifications'), + re_path(r'^.*$', NotificationsView.as_view(), name='notifications') ] # These javascript files are served "dynamically" - i.e. rendered on demand dynamic_javascript_urls = [ - re_path(r'^calendar.js', DynamicJsView.as_view(template_name='js/dynamic/calendar.js'), name='calendar.js'), - re_path(r'^nav.js', DynamicJsView.as_view(template_name='js/dynamic/nav.js'), name='nav.js'), - re_path(r'^permissions.js', DynamicJsView.as_view(template_name='js/dynamic/permissions.js'), name='permissions.js'), - re_path(r'^settings.js', DynamicJsView.as_view(template_name='js/dynamic/settings.js'), name='settings.js'), + re_path( + r'^calendar.js', + DynamicJsView.as_view(template_name='js/dynamic/calendar.js'), + name='calendar.js', + ), + re_path( + r'^nav.js', + DynamicJsView.as_view(template_name='js/dynamic/nav.js'), + name='nav.js', + ), + re_path( + r'^permissions.js', + DynamicJsView.as_view(template_name='js/dynamic/permissions.js'), + name='permissions.js', + ), + re_path( + r'^settings.js', + DynamicJsView.as_view(template_name='js/dynamic/settings.js'), + name='settings.js', + ), ] # These javascript files are passed through the Django translation layer translated_javascript_urls = [ - re_path(r'^api.js', DynamicJsView.as_view(template_name='js/translated/api.js'), name='api.js'), - re_path(r'^attachment.js', DynamicJsView.as_view(template_name='js/translated/attachment.js'), name='attachment.js'), - re_path(r'^barcode.js', DynamicJsView.as_view(template_name='js/translated/barcode.js'), name='barcode.js'), - re_path(r'^bom.js', DynamicJsView.as_view(template_name='js/translated/bom.js'), name='bom.js'), - re_path(r'^build.js', DynamicJsView.as_view(template_name='js/translated/build.js'), name='build.js'), - re_path(r'^charts.js', DynamicJsView.as_view(template_name='js/translated/charts.js'), name='charts.js'), - re_path(r'^company.js', DynamicJsView.as_view(template_name='js/translated/company.js'), name='company.js'), - re_path(r'^filters.js', DynamicJsView.as_view(template_name='js/translated/filters.js'), name='filters.js'), - re_path(r'^forms.js', DynamicJsView.as_view(template_name='js/translated/forms.js'), name='forms.js'), - re_path(r'^helpers.js', DynamicJsView.as_view(template_name='js/translated/helpers.js'), name='helpers.js'), - re_path(r'^index.js', DynamicJsView.as_view(template_name='js/translated/index.js'), name='index.js'), - re_path(r'^label.js', DynamicJsView.as_view(template_name='js/translated/label.js'), name='label.js'), - re_path(r'^model_renderers.js', DynamicJsView.as_view(template_name='js/translated/model_renderers.js'), name='model_renderers.js'), - re_path(r'^modals.js', DynamicJsView.as_view(template_name='js/translated/modals.js'), name='modals.js'), - re_path(r'^order.js', DynamicJsView.as_view(template_name='js/translated/order.js'), name='order.js'), - re_path(r'^part.js', DynamicJsView.as_view(template_name='js/translated/part.js'), name='part.js'), - re_path(r'^purchase_order.js', DynamicJsView.as_view(template_name='js/translated/purchase_order.js'), name='purchase_order.js'), - re_path(r'^return_order.js', DynamicJsView.as_view(template_name='js/translated/return_order.js'), name='return_order.js'), - re_path(r'^report.js', DynamicJsView.as_view(template_name='js/translated/report.js'), name='report.js'), - re_path(r'^sales_order.js', DynamicJsView.as_view(template_name='js/translated/sales_order.js'), name='sales_order.js'), - re_path(r'^search.js', DynamicJsView.as_view(template_name='js/translated/search.js'), name='search.js'), - re_path(r'^stock.js', DynamicJsView.as_view(template_name='js/translated/stock.js'), name='stock.js'), - re_path(r'^status_codes.js', DynamicJsView.as_view(template_name='js/translated/status_codes.js'), name='status_codes.js'), - re_path(r'^plugin.js', DynamicJsView.as_view(template_name='js/translated/plugin.js'), name='plugin.js'), - re_path(r'^pricing.js', DynamicJsView.as_view(template_name='js/translated/pricing.js'), name='pricing.js'), - re_path(r'^news.js', DynamicJsView.as_view(template_name='js/translated/news.js'), name='news.js'), - re_path(r'^tables.js', DynamicJsView.as_view(template_name='js/translated/tables.js'), name='tables.js'), - re_path(r'^table_filters.js', DynamicJsView.as_view(template_name='js/translated/table_filters.js'), name='table_filters.js'), - re_path(r'^notification.js', DynamicJsView.as_view(template_name='js/translated/notification.js'), name='notification.js'), + re_path( + r'^api.js', + DynamicJsView.as_view(template_name='js/translated/api.js'), + name='api.js', + ), + re_path( + r'^attachment.js', + DynamicJsView.as_view(template_name='js/translated/attachment.js'), + name='attachment.js', + ), + re_path( + r'^barcode.js', + DynamicJsView.as_view(template_name='js/translated/barcode.js'), + name='barcode.js', + ), + re_path( + r'^bom.js', + DynamicJsView.as_view(template_name='js/translated/bom.js'), + name='bom.js', + ), + re_path( + r'^build.js', + DynamicJsView.as_view(template_name='js/translated/build.js'), + name='build.js', + ), + re_path( + r'^charts.js', + DynamicJsView.as_view(template_name='js/translated/charts.js'), + name='charts.js', + ), + re_path( + r'^company.js', + DynamicJsView.as_view(template_name='js/translated/company.js'), + name='company.js', + ), + re_path( + r'^filters.js', + DynamicJsView.as_view(template_name='js/translated/filters.js'), + name='filters.js', + ), + re_path( + r'^forms.js', + DynamicJsView.as_view(template_name='js/translated/forms.js'), + name='forms.js', + ), + re_path( + r'^helpers.js', + DynamicJsView.as_view(template_name='js/translated/helpers.js'), + name='helpers.js', + ), + re_path( + r'^index.js', + DynamicJsView.as_view(template_name='js/translated/index.js'), + name='index.js', + ), + re_path( + r'^label.js', + DynamicJsView.as_view(template_name='js/translated/label.js'), + name='label.js', + ), + re_path( + r'^model_renderers.js', + DynamicJsView.as_view(template_name='js/translated/model_renderers.js'), + name='model_renderers.js', + ), + re_path( + r'^modals.js', + DynamicJsView.as_view(template_name='js/translated/modals.js'), + name='modals.js', + ), + re_path( + r'^order.js', + DynamicJsView.as_view(template_name='js/translated/order.js'), + name='order.js', + ), + re_path( + r'^part.js', + DynamicJsView.as_view(template_name='js/translated/part.js'), + name='part.js', + ), + re_path( + r'^purchase_order.js', + DynamicJsView.as_view(template_name='js/translated/purchase_order.js'), + name='purchase_order.js', + ), + re_path( + r'^return_order.js', + DynamicJsView.as_view(template_name='js/translated/return_order.js'), + name='return_order.js', + ), + re_path( + r'^report.js', + DynamicJsView.as_view(template_name='js/translated/report.js'), + name='report.js', + ), + re_path( + r'^sales_order.js', + DynamicJsView.as_view(template_name='js/translated/sales_order.js'), + name='sales_order.js', + ), + re_path( + r'^search.js', + DynamicJsView.as_view(template_name='js/translated/search.js'), + name='search.js', + ), + re_path( + r'^stock.js', + DynamicJsView.as_view(template_name='js/translated/stock.js'), + name='stock.js', + ), + re_path( + r'^status_codes.js', + DynamicJsView.as_view(template_name='js/translated/status_codes.js'), + name='status_codes.js', + ), + re_path( + r'^plugin.js', + DynamicJsView.as_view(template_name='js/translated/plugin.js'), + name='plugin.js', + ), + re_path( + r'^pricing.js', + DynamicJsView.as_view(template_name='js/translated/pricing.js'), + name='pricing.js', + ), + re_path( + r'^news.js', + DynamicJsView.as_view(template_name='js/translated/news.js'), + name='news.js', + ), + re_path( + r'^tables.js', + DynamicJsView.as_view(template_name='js/translated/tables.js'), + name='tables.js', + ), + re_path( + r'^table_filters.js', + DynamicJsView.as_view(template_name='js/translated/table_filters.js'), + name='table_filters.js', + ), + re_path( + r'^notification.js', + DynamicJsView.as_view(template_name='js/translated/notification.js'), + name='notification.js', + ), ] backendpatterns = [ # "Dynamic" javascript files which are rendered using InvenTree templating. re_path(r'^js/dynamic/', include(dynamic_javascript_urls)), re_path(r'^js/i18n/', include(translated_javascript_urls)), - re_path(r'^auth/', include('rest_framework.urls', namespace='rest_framework')), re_path(r'^auth/?', auth_request), - re_path(r'^api/', include(apipatterns)), - re_path(r'^api-doc/', SpectacularRedocView.as_view(url_name='schema'), name='api-doc'), + re_path( + r'^api-doc/', SpectacularRedocView.as_view(url_name='schema'), name='api-doc' + ), ] classic_frontendpatterns = [ - # Apps re_path(r'^build/', include(build_urls)), re_path(r'^common/', include(common_urls)), @@ -189,38 +375,48 @@ classic_frontendpatterns = [ re_path(r'^part/', include(part_urls)), re_path(r'^stock/', include(stock_urls)), re_path(r'^supplier-part/', include(supplier_part_urls)), - re_path(r'^edit-user/', EditUserView.as_view(), name='edit-user'), re_path(r'^set-password/', SetPasswordView.as_view(), name='set-password'), - re_path(r'^index/', IndexView.as_view(), name='index'), re_path(r'^notifications/', include(notifications_urls)), re_path(r'^search/', SearchView.as_view(), name='search'), re_path(r'^settings/', include(settings_urls)), re_path(r'^about/', AboutView.as_view(), name='about'), re_path(r'^stats/', DatabaseStatsView.as_view(), name='stats'), - # DB user sessions - path('accounts/sessions/other/delete/', view=CustomSessionDeleteOtherView.as_view(), name='session_delete_other', ), - re_path(r'^accounts/sessions/(?P\w+)/delete/$', view=CustomSessionDeleteView.as_view(), name='session_delete', ), - + path( + 'accounts/sessions/other/delete/', + view=CustomSessionDeleteOtherView.as_view(), + name='session_delete_other', + ), + re_path( + r'^accounts/sessions/(?P\w+)/delete/$', + view=CustomSessionDeleteView.as_view(), + name='session_delete', + ), # Single Sign On / allauth # overrides of urlpatterns re_path(r'^accounts/email/', CustomEmailView.as_view(), name='account_email'), - re_path(r'^accounts/social/connections/', CustomConnectionsView.as_view(), name='socialaccount_connections'), - re_path(r"^accounts/password/reset/key/(?P[0-9A-Za-z]+)-(?P.+)/$", CustomPasswordResetFromKeyView.as_view(), name="account_reset_password_from_key"), - + re_path( + r'^accounts/social/connections/', + CustomConnectionsView.as_view(), + name='socialaccount_connections', + ), + re_path( + r"^accounts/password/reset/key/(?P[0-9A-Za-z]+)-(?P.+)/$", + CustomPasswordResetFromKeyView.as_view(), + name="account_reset_password_from_key", + ), # Override login page re_path("accounts/login/", CustomLoginView.as_view(), name="account_login"), - - re_path(r'^accounts/', include('allauth_2fa.urls')), # MFA support - re_path(r'^accounts/', include('allauth.urls')), # included urlpatterns + re_path(r'^accounts/', include('allauth_2fa.urls')), # MFA support + re_path(r'^accounts/', include('allauth.urls')), # included urlpatterns ] urlpatterns = [] if settings.INVENTREE_ADMIN_ENABLED: - admin_url = settings.INVENTREE_ADMIN_URL, + admin_url = (settings.INVENTREE_ADMIN_URL,) urlpatterns += [ path(f'{admin_url}/error_log/', include('error_report.urls')), path(f'{admin_url}/', admin.site.urls, name='inventree-admin'), @@ -252,14 +448,18 @@ if settings.DEBUG: # Debug toolbar access (only allowed in DEBUG mode) if settings.DEBUG_TOOLBAR_ENABLED: import debug_toolbar - urlpatterns = [ - path('__debug__/', include(debug_toolbar.urls)), - ] + urlpatterns + + urlpatterns = [path('__debug__/', include(debug_toolbar.urls))] + urlpatterns # Redirect for favicon.ico urlpatterns.append( - path('favicon.ico', RedirectView.as_view(url=f'{settings.STATIC_URL}img/favicon/favicon.ico')) + path( + 'favicon.ico', + RedirectView.as_view(url=f'{settings.STATIC_URL}img/favicon/favicon.ico'), + ) ) # Send any unknown URLs to the parts page -urlpatterns += [re_path(r'^.*$', RedirectView.as_view(url='/index/', permanent=False), name='index')] +urlpatterns += [ + re_path(r'^.*$', RedirectView.as_view(url='/index/', permanent=False), name='index') +] diff --git a/InvenTree/InvenTree/validators.py b/InvenTree/InvenTree/validators.py index ea9111585d..bec31d2f09 100644 --- a/InvenTree/InvenTree/validators.py +++ b/InvenTree/InvenTree/validators.py @@ -69,7 +69,9 @@ class AllowedURLValidator(validators.URLValidator): self.schemes = allowable_url_schemes() # Determine if 'strict' URL validation is required (i.e. if the URL must have a schema prefix) - strict_urls = common.models.InvenTreeSetting.get_setting('INVENTREE_STRICT_URLS', True, cache=False) + strict_urls = common.models.InvenTreeSetting.get_setting( + 'INVENTREE_STRICT_URLS', True, cache=False + ) if not strict_urls: # Allow URLs which do not have a provided schema @@ -141,9 +143,7 @@ def validate_overage(value): except ValueError: pass - raise ValidationError( - _("Invalid value for overage") - ) + raise ValidationError(_("Invalid value for overage")) def validate_part_name_format(value): @@ -179,8 +179,6 @@ def validate_part_name_format(value): try: Template(value).render({'part': p}) except Exception as exc: - raise ValidationError({ - 'value': str(exc) - }) + raise ValidationError({'value': str(exc)}) return True diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index ee0f1212eb..997b4fdddd 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -105,7 +105,7 @@ def inventreeDocUrl(): def inventreeAppUrl(): """Return URL for InvenTree app site.""" - return f'{inventreeDocUrl()}/app/app', + return (f'{inventreeDocUrl()}/app/app',) def inventreeCreditsUrl(): @@ -124,7 +124,10 @@ def isInvenTreeUpToDate(): A background task periodically queries GitHub for latest version, and stores it to the database as "_INVENTREE_LATEST_VERSION" """ import common.models - latest = common.models.InvenTreeSetting.get_setting('_INVENTREE_LATEST_VERSION', backup_value=None, create=False) + + latest = common.models.InvenTreeSetting.get_setting( + '_INVENTREE_LATEST_VERSION', backup_value=None, create=False + ) # No record for "latest" version - we must assume we are up to date! if not latest: @@ -153,7 +156,9 @@ def parse_version_text(): data = version.split("\n") version_split = data[0].split(' -> ') - version_detail = version_split[1].split(':', 1) if len(version_split) > 1 else ['', ] + version_detail = ( + version_split[1].split(':', 1) if len(version_split) > 1 else [''] + ) new_data = { "version": version_split[0].strip(), "date": version_detail[0].strip(), @@ -222,7 +227,9 @@ def inventreeCommitDate(): if main_commit is None: return None - commit_dt = dt.fromtimestamp(main_commit.commit_time) + td(seconds=main_commit.commit_timezone) + commit_dt = dt.fromtimestamp(main_commit.commit_time) + td( + seconds=main_commit.commit_timezone + ) return str(commit_dt.date()) diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py index 9a6f4e95f2..7c7767c763 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -5,8 +5,7 @@ as JSON objects and passing them to modal forms (using jQuery / bootstrap). """ from django.contrib.auth import password_validation -from django.contrib.auth.mixins import (LoginRequiredMixin, - PermissionRequiredMixin) +from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.core.exceptions import ValidationError from django.http import HttpResponse, HttpResponseRedirect, JsonResponse from django.shortcuts import redirect @@ -20,8 +19,7 @@ from django.views.generic.base import RedirectView, TemplateView from allauth.account.forms import AddEmailForm from allauth.account.models import EmailAddress -from allauth.account.views import (EmailView, LoginView, - PasswordResetFromKeyView) +from allauth.account.views import EmailView, LoginView, PasswordResetFromKeyView from allauth.socialaccount.forms import DisconnectForm from allauth.socialaccount.views import ConnectionsView from djmoney.contrib.exchange.models import ExchangeBackend, Rate @@ -104,7 +102,6 @@ class InvenTreeRoleMixin(PermissionRequiredMixin): return True for required in roles_required: - (role, permission) = required.split('.') if role not in RuleSet.RULESET_NAMES: @@ -143,7 +140,9 @@ class InvenTreeRoleMixin(PermissionRequiredMixin): permission = self.get_permission_class() if not permission: - raise AttributeError(f"permission_class not defined for {type(self).__name__}") + raise AttributeError( + f"permission_class not defined for {type(self).__name__}" + ) # Check if the user has the required permission return RuleSet.check_table_permission(user, table, permission) @@ -182,7 +181,6 @@ class InvenTreeRoleMixin(PermissionRequiredMixin): } for view_class in permission_map.keys(): - if issubclass(type(self), view_class): return permission_map[view_class] @@ -282,9 +280,7 @@ class AjaxMixin(InvenTreeRoleMixin): data['title'] = self.get_form_title() data['html_form'] = render_to_string( - self.ajax_template_name, - context, - request=request + self.ajax_template_name, context, request=request ) # Custom feedback`data @@ -329,7 +325,9 @@ class AjaxUpdateView(AjaxMixin, UpdateView): """ super(UpdateView, self).get(request, *args, **kwargs) - return self.renderJsonResponse(request, self.get_form(), context=self.get_context_data()) + return self.renderJsonResponse( + request, self.get_form(), context=self.get_context_data() + ) def save(self, object, form, **kwargs): """Method for updating the object in the database. Default implementation is very simple, but can be overridden if required. @@ -379,7 +377,6 @@ class AjaxUpdateView(AjaxMixin, UpdateView): data[key] = value if valid: - # Save the updated object to the database self.save(self.object, form) @@ -514,7 +511,9 @@ class SettingsView(TemplateView): ctx["rates"] = Rate.objects.filter(backend="InvenTreeExchange") - ctx["categories"] = PartCategory.objects.all().order_by('tree_id', 'lft', 'name') + ctx["categories"] = PartCategory.objects.all().order_by( + 'tree_id', 'lft', 'name' + ) # When were the rates last updated? try: @@ -535,7 +534,9 @@ class SettingsView(TemplateView): # user db sessions ctx['session_key'] = self.request.session.session_key - ctx['session_list'] = self.request.user.session_set.filter(expire_date__gt=now()).order_by('-last_activity') + ctx['session_list'] = self.request.user.session_set.filter( + expire_date__gt=now() + ).order_by('-last_activity') return ctx @@ -550,20 +551,23 @@ class AllauthOverrides(LoginRequiredMixin): class CustomEmailView(AllauthOverrides, EmailView): """Override of allauths EmailView to always show the settings but leave the functions allow.""" + success_url = reverse_lazy("settings") class CustomConnectionsView(AllauthOverrides, ConnectionsView): """Override of allauths ConnectionsView to always show the settings but leave the functions allow.""" + success_url = reverse_lazy("settings") class CustomPasswordResetFromKeyView(PasswordResetFromKeyView): """Override of allauths PasswordResetFromKeyView to always show the settings but leave the functions allow.""" + success_url = reverse_lazy("account_login") -class UserSessionOverride(): +class UserSessionOverride: """Overrides sucessurl to lead to settings.""" def get_success_url(self): @@ -573,11 +577,13 @@ class UserSessionOverride(): class CustomSessionDeleteView(UserSessionOverride, SessionDeleteView): """Revert to settings after session delete.""" + pass class CustomSessionDeleteOtherView(UserSessionOverride, SessionDeleteOtherView): """Revert to settings after session delete.""" + pass @@ -607,7 +613,9 @@ class AppearanceSelectView(RedirectView): def get_user_theme(self): """Get current user color theme.""" try: - user_theme = common_models.ColorTheme.objects.filter(user=self.request.user).get() + user_theme = common_models.ColorTheme.objects.filter( + user=self.request.user + ).get() except common_models.ColorTheme.DoesNotExist: user_theme = None diff --git a/InvenTree/InvenTree/wsgi.py b/InvenTree/InvenTree/wsgi.py index dfced329a8..2a20d06c0d 100644 --- a/InvenTree/InvenTree/wsgi.py +++ b/InvenTree/InvenTree/wsgi.py @@ -10,6 +10,8 @@ import os # pragma: no cover from django.core.wsgi import get_wsgi_application # pragma: no cover -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "InvenTree.settings") # pragma: no cover +os.environ.setdefault( + "DJANGO_SETTINGS_MODULE", "InvenTree.settings" +) # pragma: no cover application = get_wsgi_application() # pragma: no cover diff --git a/InvenTree/common/admin.py b/InvenTree/common/admin.py index cbc5b6621b..528f705c3a 100644 --- a/InvenTree/common/admin.py +++ b/InvenTree/common/admin.py @@ -22,7 +22,7 @@ class SettingsAdmin(ImportExportModelAdmin): class UserSettingsAdmin(ImportExportModelAdmin): """Admin settings for InvenTreeUserSetting.""" - list_display = ('key', 'value', 'user', ) + list_display = ('key', 'value', 'user') def get_readonly_fields(self, request, obj=None): # pragma: no cover """Prevent the 'key' field being edited once the setting is created.""" @@ -40,23 +40,31 @@ class WebhookAdmin(ImportExportModelAdmin): class NotificationEntryAdmin(admin.ModelAdmin): """Admin settings for NotificationEntry.""" - list_display = ('key', 'uid', 'updated', ) + list_display = ('key', 'uid', 'updated') class NotificationMessageAdmin(admin.ModelAdmin): """Admin settings for NotificationMessage.""" - list_display = ('age_human', 'user', 'category', 'name', 'read', 'target_object', 'source_object', ) + list_display = ( + 'age_human', + 'user', + 'category', + 'name', + 'read', + 'target_object', + 'source_object', + ) - list_filter = ('category', 'read', 'user', ) + list_filter = ('category', 'read', 'user') - search_fields = ('name', 'category', 'message', ) + search_fields = ('name', 'category', 'message') class NewsFeedEntryAdmin(admin.ModelAdmin): """Admin settings for NewsFeedEntry.""" - list_display = ('title', 'author', 'published', 'summary', ) + list_display = ('title', 'author', 'published', 'summary') admin.site.register(common.models.InvenTreeSetting, SettingsAdmin) diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py index 3b9e15bbdc..6187772569 100644 --- a/InvenTree/common/api.py +++ b/InvenTree/common/api.py @@ -23,8 +23,13 @@ from InvenTree.api import BulkDeleteMixin, MetadataView from InvenTree.config import CONFIG_LOOKUPS from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER from InvenTree.helpers import inheritors -from InvenTree.mixins import (ListAPI, ListCreateAPI, RetrieveAPI, - RetrieveUpdateAPI, RetrieveUpdateDestroyAPI) +from InvenTree.mixins import ( + ListAPI, + ListCreateAPI, + RetrieveAPI, + RetrieveUpdateAPI, + RetrieveUpdateDestroyAPI, +) from InvenTree.permissions import IsStaffOrReadOnly, IsSuperuser from plugin.models import NotificationUserSetting from plugin.serializers import NotificationUserSettingSerializer @@ -41,6 +46,7 @@ class CsrfExemptMixin(object): class WebhookView(CsrfExemptMixin, APIView): """Endpoint for receiving webhooks.""" + authentication_classes = [] permission_classes = [] model_class = common.models.WebhookEndpoint @@ -66,8 +72,7 @@ class WebhookView(CsrfExemptMixin, APIView): async_task(self._process_payload, message.id) else: self._process_result( - self.webhook.process_payload(message, payload, headers), - message, + self.webhook.process_payload(message, payload, headers), message ) data = self.webhook.get_return(payload, headers, request) @@ -76,8 +81,7 @@ class WebhookView(CsrfExemptMixin, APIView): def _process_payload(self, message_id): message = common.models.WebhookMessage.objects.get(message_id=message_id) self._process_result( - self.webhook.process_payload(message, message.body, message.header), - message, + self.webhook.process_payload(message, message.body, message.header), message ) def _process_result(self, result, message): @@ -108,9 +112,7 @@ class WebhookView(CsrfExemptMixin, APIView): class CurrencyExchangeView(APIView): """API endpoint for displaying currency information""" - permission_classes = [ - permissions.IsAuthenticated, - ] + permission_classes = [permissions.IsAuthenticated] def get(self, request, format=None): """Return information on available currency conversions""" @@ -133,7 +135,9 @@ class CurrencyExchangeView(APIView): updated = None response = { - 'base_currency': common.models.InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY', 'USD'), + 'base_currency': common.models.InvenTreeSetting.get_setting( + 'INVENTREE_DEFAULT_CURRENCY', 'USD' + ), 'exchange_rates': {}, 'updated': updated, } @@ -150,10 +154,7 @@ class CurrencyRefreshView(APIView): User must be a 'staff' user to access this endpoint """ - permission_classes = [ - permissions.IsAuthenticated, - permissions.IsAdminUser, - ] + permission_classes = [permissions.IsAuthenticated, permissions.IsAdminUser] def post(self, request, *args, **kwargs): """Performing a POST request will update currency exchange rates""" @@ -161,9 +162,7 @@ class CurrencyRefreshView(APIView): update_exchange_rates(force=True) - return Response({ - 'success': 'Exchange rates updated', - }) + return Response({'success': 'Exchange rates updated'}) class SettingsList(ListAPI): @@ -174,15 +173,9 @@ class SettingsList(ListAPI): filter_backends = SEARCH_ORDER_FILTER - ordering_fields = [ - 'pk', - 'key', - 'name', - ] + ordering_fields = ['pk', 'key', 'name'] - search_fields = [ - 'key', - ] + search_fields = ['key'] class GlobalSettingsList(SettingsList): @@ -228,18 +221,17 @@ class GlobalSettingsDetail(RetrieveUpdateAPI): """Attempt to find a global setting object with the provided key.""" key = str(self.kwargs['key']).upper() - if key.startswith('_') or key not in common.models.InvenTreeSetting.SETTINGS.keys(): + if ( + key.startswith('_') + or key not in common.models.InvenTreeSetting.SETTINGS.keys() + ): raise NotFound() return common.models.InvenTreeSetting.get_setting_object( - key, - cache=False, create=True + key, cache=False, create=True ) - permission_classes = [ - permissions.IsAuthenticated, - GlobalSettingsPermissions, - ] + permission_classes = [permissions.IsAuthenticated, GlobalSettingsPermissions] class UserSettingsList(SettingsList): @@ -294,18 +286,17 @@ class UserSettingsDetail(RetrieveUpdateAPI): """Attempt to find a user setting object with the provided key.""" key = str(self.kwargs['key']).upper() - if key.startswith('_') or key not in common.models.InvenTreeUserSetting.SETTINGS.keys(): + if ( + key.startswith('_') + or key not in common.models.InvenTreeUserSetting.SETTINGS.keys() + ): raise NotFound() return common.models.InvenTreeUserSetting.get_setting_object( - key, - user=self.request.user, - cache=False, create=True + key, user=self.request.user, cache=False, create=True ) - permission_classes = [ - UserSettingsPermissions, - ] + permission_classes = [UserSettingsPermissions] class NotificationUserSettingsList(SettingsList): @@ -334,39 +325,29 @@ class NotificationUserSettingsDetail(RetrieveUpdateAPI): queryset = NotificationUserSetting.objects.all() serializer_class = NotificationUserSettingSerializer - permission_classes = [UserSettingsPermissions, ] + permission_classes = [UserSettingsPermissions] class NotificationMessageMixin: """Generic mixin for NotificationMessage.""" + queryset = common.models.NotificationMessage.objects.all() serializer_class = common.serializers.NotificationMessageSerializer - permission_classes = [UserSettingsPermissions, ] + permission_classes = [UserSettingsPermissions] class NotificationList(NotificationMessageMixin, BulkDeleteMixin, ListAPI): """List view for all notifications of the current user.""" - permission_classes = [permissions.IsAuthenticated, ] + permission_classes = [permissions.IsAuthenticated] filter_backends = SEARCH_ORDER_FILTER - ordering_fields = [ - 'category', - 'name', - 'read', - 'creation', - ] + ordering_fields = ['category', 'name', 'read', 'creation'] - search_fields = [ - 'name', - 'message', - ] + search_fields = ['name', 'message'] - filterset_fields = [ - 'category', - 'read', - ] + filterset_fields = ['category', 'read'] def filter_queryset(self, queryset): """Only list notifications which apply to the current user.""" @@ -401,29 +382,27 @@ class NotificationReadAll(NotificationMessageMixin, RetrieveAPI): self.queryset.filter(user=request.user, read=False).update(read=True) return Response({'status': 'ok'}) except Exception as exc: - raise serializers.ValidationError(detail=serializers.as_serializer_error(exc)) + raise serializers.ValidationError( + detail=serializers.as_serializer_error(exc) + ) class NewsFeedMixin: """Generic mixin for NewsFeedEntry.""" + queryset = common.models.NewsFeedEntry.objects.all() serializer_class = common.serializers.NewsFeedEntrySerializer - permission_classes = [IsAdminUser, ] + permission_classes = [IsAdminUser] class NewsFeedEntryList(NewsFeedMixin, BulkDeleteMixin, ListAPI): """List view for all news items.""" + filter_backends = ORDER_FILTER - ordering_fields = [ - 'published', - 'author', - 'read', - ] + ordering_fields = ['published', 'author', 'read'] - filterset_fields = [ - 'read', - ] + filterset_fields = ['read'] class NewsFeedEntryDetail(NewsFeedMixin, RetrieveUpdateDestroyAPI): @@ -435,14 +414,14 @@ class ConfigList(ListAPI): queryset = CONFIG_LOOKUPS serializer_class = common.serializers.ConfigSerializer - permission_classes = [IsSuperuser, ] + permission_classes = [IsSuperuser] class ConfigDetail(RetrieveAPI): """Detail view for an individual configuration.""" serializer_class = common.serializers.ConfigSerializer - permission_classes = [IsSuperuser, ] + permission_classes = [IsSuperuser] def get_object(self): """Attempt to find a config object with the provided key.""" @@ -458,7 +437,7 @@ class NotesImageList(ListCreateAPI): queryset = common.models.NotesImage.objects.all() serializer_class = common.serializers.NotesImageSerializer - permission_classes = [permissions.IsAuthenticated, ] + permission_classes = [permissions.IsAuthenticated] def perform_create(self, serializer): """Create (upload) a new notes image""" @@ -475,14 +454,9 @@ class ProjectCodeList(ListCreateAPI): permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly] filter_backends = SEARCH_ORDER_FILTER - ordering_fields = [ - 'code', - ] + ordering_fields = ['code'] - search_fields = [ - 'code', - 'description', - ] + search_fields = ['code', 'description'] class ProjectCodeDetail(RetrieveUpdateDestroyAPI): @@ -515,14 +489,14 @@ class FlagList(ListAPI): queryset = settings.FLAGS serializer_class = common.serializers.FlagSerializer - permission_classes = [permissions.AllowAny, ] + permission_classes = [permissions.AllowAny] class FlagDetail(RetrieveAPI): """Detail view for an individual feature flag.""" serializer_class = common.serializers.FlagSerializer - permission_classes = [permissions.AllowAny, ] + permission_classes = [permissions.AllowAny] def get_object(self): """Attempt to find a config object with the provided key.""" @@ -535,97 +509,175 @@ class FlagDetail(RetrieveAPI): settings_api_urls = [ # User settings - re_path(r'^user/', include([ - # User Settings Detail - re_path(r'^(?P\w+)/', UserSettingsDetail.as_view(), name='api-user-setting-detail'), - - # User Settings List - re_path(r'^.*$', UserSettingsList.as_view(), name='api-user-setting-list'), - ])), - + re_path( + r'^user/', + include([ + # User Settings Detail + re_path( + r'^(?P\w+)/', + UserSettingsDetail.as_view(), + name='api-user-setting-detail', + ), + # User Settings List + re_path(r'^.*$', UserSettingsList.as_view(), name='api-user-setting-list'), + ]), + ), # Notification settings - re_path(r'^notification/', include([ - # Notification Settings Detail - path(r'/', NotificationUserSettingsDetail.as_view(), name='api-notification-setting-detail'), - - # Notification Settings List - re_path(r'^.*$', NotificationUserSettingsList.as_view(), name='api-notification-setting-list'), - ])), - + re_path( + r'^notification/', + include([ + # Notification Settings Detail + path( + r'/', + NotificationUserSettingsDetail.as_view(), + name='api-notification-setting-detail', + ), + # Notification Settings List + re_path( + r'^.*$', + NotificationUserSettingsList.as_view(), + name='api-notification-setting-list', + ), + ]), + ), # Global settings - re_path(r'^global/', include([ - # Global Settings Detail - re_path(r'^(?P\w+)/', GlobalSettingsDetail.as_view(), name='api-global-setting-detail'), - - # Global Settings List - re_path(r'^.*$', GlobalSettingsList.as_view(), name='api-global-setting-list'), - ])), + re_path( + r'^global/', + include([ + # Global Settings Detail + re_path( + r'^(?P\w+)/', + GlobalSettingsDetail.as_view(), + name='api-global-setting-detail', + ), + # Global Settings List + re_path( + r'^.*$', GlobalSettingsList.as_view(), name='api-global-setting-list' + ), + ]), + ), ] common_api_urls = [ # Webhooks path('webhook//', WebhookView.as_view(), name='api-webhook'), - # Uploaded images for notes - re_path(r'^notes-image-upload/', NotesImageList.as_view(), name='api-notes-image-list'), - + re_path( + r'^notes-image-upload/', NotesImageList.as_view(), name='api-notes-image-list' + ), # Project codes - re_path(r'^project-code/', include([ - path(r'/', include([ - re_path(r'^metadata/', MetadataView.as_view(), {'model': common.models.ProjectCode}, name='api-project-code-metadata'), - re_path(r'^.*$', ProjectCodeDetail.as_view(), name='api-project-code-detail'), - ])), - re_path(r'^.*$', ProjectCodeList.as_view(), name='api-project-code-list'), - ])), - + re_path( + r'^project-code/', + include([ + path( + r'/', + include([ + re_path( + r'^metadata/', + MetadataView.as_view(), + {'model': common.models.ProjectCode}, + name='api-project-code-metadata', + ), + re_path( + r'^.*$', + ProjectCodeDetail.as_view(), + name='api-project-code-detail', + ), + ]), + ), + re_path(r'^.*$', ProjectCodeList.as_view(), name='api-project-code-list'), + ]), + ), # Custom physical units - re_path(r'^units/', include([ - path(r'/', include([ - re_path(r'^.*$', CustomUnitDetail.as_view(), name='api-custom-unit-detail'), - ])), - re_path(r'^.*$', CustomUnitList.as_view(), name='api-custom-unit-list'), - ])), - + re_path( + r'^units/', + include([ + path( + r'/', + include([ + re_path( + r'^.*$', + CustomUnitDetail.as_view(), + name='api-custom-unit-detail', + ) + ]), + ), + re_path(r'^.*$', CustomUnitList.as_view(), name='api-custom-unit-list'), + ]), + ), # Currencies - re_path(r'^currency/', include([ - re_path(r'^exchange/', CurrencyExchangeView.as_view(), name='api-currency-exchange'), - re_path(r'^refresh/', CurrencyRefreshView.as_view(), name='api-currency-refresh'), - ])), - + re_path( + r'^currency/', + include([ + re_path( + r'^exchange/', + CurrencyExchangeView.as_view(), + name='api-currency-exchange', + ), + re_path( + r'^refresh/', CurrencyRefreshView.as_view(), name='api-currency-refresh' + ), + ]), + ), # Notifications - re_path(r'^notifications/', include([ - # Individual purchase order detail URLs - path(r'/', include([ - re_path(r'.*$', NotificationDetail.as_view(), name='api-notifications-detail'), - ])), - # Read all - re_path(r'^readall/', NotificationReadAll.as_view(), name='api-notifications-readall'), - - # Notification messages list - re_path(r'^.*$', NotificationList.as_view(), name='api-notifications-list'), - ])), - + re_path( + r'^notifications/', + include([ + # Individual purchase order detail URLs + path( + r'/', + include([ + re_path( + r'.*$', + NotificationDetail.as_view(), + name='api-notifications-detail', + ) + ]), + ), + # Read all + re_path( + r'^readall/', + NotificationReadAll.as_view(), + name='api-notifications-readall', + ), + # Notification messages list + re_path(r'^.*$', NotificationList.as_view(), name='api-notifications-list'), + ]), + ), # News - re_path(r'^news/', include([ - path(r'/', include([ - re_path(r'.*$', NewsFeedEntryDetail.as_view(), name='api-news-detail'), - ])), - re_path(r'^.*$', NewsFeedEntryList.as_view(), name='api-news-list'), - ])), - + re_path( + r'^news/', + include([ + path( + r'/', + include([ + re_path( + r'.*$', NewsFeedEntryDetail.as_view(), name='api-news-detail' + ) + ]), + ), + re_path(r'^.*$', NewsFeedEntryList.as_view(), name='api-news-list'), + ]), + ), # Flags - path('flags/', include([ - path('/', FlagDetail.as_view(), name='api-flag-detail'), - re_path(r'^.*$', FlagList.as_view(), name='api-flag-list'), - ])), - + path( + 'flags/', + include([ + path('/', FlagDetail.as_view(), name='api-flag-detail'), + re_path(r'^.*$', FlagList.as_view(), name='api-flag-list'), + ]), + ), # Status - path('generic/status/', include([ - path(f'/', include([ - path('', StatusView.as_view(), name='api-status'), - ])), - path('', AllStatusViews.as_view(), name='api-status-all'), - ])), + path( + 'generic/status/', + include([ + path( + f'/', + include([path('', StatusView.as_view(), name='api-status')]), + ), + path('', AllStatusViews.as_view(), name='api-status-all'), + ]), + ), ] admin_api_urls = [ diff --git a/InvenTree/common/apps.py b/InvenTree/common/apps.py index 084b101bf6..f05808a4d8 100644 --- a/InvenTree/common/apps.py +++ b/InvenTree/common/apps.py @@ -26,10 +26,14 @@ class CommonConfig(AppConfig): try: import common.models - if common.models.InvenTreeSetting.get_setting('SERVER_RESTART_REQUIRED', backup_value=False, create=False, cache=False): + if common.models.InvenTreeSetting.get_setting( + 'SERVER_RESTART_REQUIRED', backup_value=False, create=False, cache=False + ): logger.info("Clearing SERVER_RESTART_REQUIRED flag") if not isImportingData(): - common.models.InvenTreeSetting.set_setting('SERVER_RESTART_REQUIRED', False, None) + common.models.InvenTreeSetting.set_setting( + 'SERVER_RESTART_REQUIRED', False, None + ) except Exception: pass diff --git a/InvenTree/common/files.py b/InvenTree/common/files.py index d18ca34c98..37d85f46aa 100644 --- a/InvenTree/common/files.py +++ b/InvenTree/common/files.py @@ -49,12 +49,12 @@ class FileManager: ext = os.path.splitext(file.name)[-1].lower().replace('.', '') try: - if ext in ['csv', 'tsv', ]: + if ext in ['csv', 'tsv']: # These file formats need string decoding raw_data = file.read().decode('utf-8') # Reset stream position to beginning of file file.seek(0) - elif ext in ['xls', 'xlsx', 'json', 'yaml', ]: + elif ext in ['xls', 'xlsx', 'json', 'yaml']: raw_data = file.read() # Reset stream position to beginning of file file.seek(0) @@ -81,7 +81,12 @@ class FileManager: def update_headers(self): """Update headers.""" - self.HEADERS = self.REQUIRED_HEADERS + self.ITEM_MATCH_HEADERS + self.OPTIONAL_MATCH_HEADERS + self.OPTIONAL_HEADERS + self.HEADERS = ( + self.REQUIRED_HEADERS + + self.ITEM_MATCH_HEADERS + + self.OPTIONAL_MATCH_HEADERS + + self.OPTIONAL_HEADERS + ) def setup(self): """Setup headers should be overridden in usage to set the Different Headers.""" @@ -149,15 +154,9 @@ class FileManager: break if not guess_exists: - headers.append({ - 'name': header, - 'guess': guess - }) + headers.append({'name': header, 'guess': guess}) else: - headers.append({ - 'name': header, - 'guess': None - }) + headers.append({'name': header, 'guess': None}) return headers @@ -180,7 +179,6 @@ class FileManager: rows = [] for i in range(self.row_count()): - data = list(self.get_row_data(i)) # Is the row completely empty? Skip! @@ -203,10 +201,7 @@ class FileManager: if empty: continue - row = { - 'data': data, - 'index': i - } + row = {'data': data, 'index': i} rows.append(row) diff --git a/InvenTree/common/forms.py b/InvenTree/common/forms.py index b58f1ff352..b991eec06b 100644 --- a/InvenTree/common/forms.py +++ b/InvenTree/common/forms.py @@ -9,10 +9,7 @@ from .files import FileManager class UploadFileForm(forms.Form): """Step 1 of FileManagementFormView.""" - file = forms.FileField( - label=_('File'), - help_text=_('Select file to upload'), - ) + file = forms.FileField(label=_('File'), help_text=_('Select file to upload')) def __init__(self, *args, **kwargs): """Update label and help_text.""" @@ -67,9 +64,7 @@ class MatchFieldForm(forms.Form): self.fields[field_name] = forms.ChoiceField( choices=[('', '-' * 10)] + headers_choices, required=False, - widget=forms.Select(attrs={ - 'class': 'select fieldselect', - }) + widget=forms.Select(attrs={'class': 'select fieldselect'}), ) if col['guess']: self.fields[field_name].initial = col['guess'] @@ -107,7 +102,9 @@ class MatchItemForm(forms.Form): field_name = col_guess.lower() + '-' + str(row['index']) # check if field def was overridden - overriden_field = self.get_special_field(col_guess, row, file_manager) + overriden_field = self.get_special_field( + col_guess, row, file_manager + ) if overriden_field: self.fields[field_name] = overriden_field @@ -117,23 +114,23 @@ class MatchItemForm(forms.Form): value = row.get(col_guess.lower(), '') # Set field input box self.fields[field_name] = forms.CharField( - required=True, - initial=value, + required=True, initial=value ) # Create item selection box elif col_guess in file_manager.OPTIONAL_MATCH_HEADERS: # Get item options - item_options = [(option.id, option) for option in row['match_options_' + col_guess]] + item_options = [ + (option.id, option) + for option in row['match_options_' + col_guess] + ] # Get item match item_match = row['match_' + col_guess] # Set field select box self.fields[field_name] = forms.ChoiceField( choices=[('', '-' * 10)] + item_options, required=False, - widget=forms.Select(attrs={ - 'class': 'select bomselect', - }) + widget=forms.Select(attrs={'class': 'select bomselect'}), ) # Update select box when match was found if item_match: @@ -142,7 +139,9 @@ class MatchItemForm(forms.Form): # Create item selection box elif col_guess in file_manager.ITEM_MATCH_HEADERS: # Get item options - item_options = [(option.id, option) for option in row['item_options']] + item_options = [ + (option.id, option) for option in row['item_options'] + ] # Get item match item_match = row['item_match'] # Set field name @@ -151,9 +150,7 @@ class MatchItemForm(forms.Form): self.fields[field_name] = forms.ChoiceField( choices=[('', '-' * 10)] + item_options, required=False, - widget=forms.Select(attrs={ - 'class': 'select bomselect', - }) + widget=forms.Select(attrs={'class': 'select bomselect'}), ) # Update select box when match was found if item_match: @@ -169,8 +166,7 @@ class MatchItemForm(forms.Form): value = row.get(col_guess.lower(), '') # Set field input box self.fields[field_name] = forms.CharField( - required=False, - initial=value, + required=False, initial=value ) def get_special_field(self, col_guess, row, file_manager): diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 649ea6adfa..076e11cade 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -27,8 +27,7 @@ from django.contrib.humanize.templatetags.humanize import naturaltime from django.contrib.sites.models import Site from django.core.cache import cache from django.core.exceptions import AppRegistryNotReady, ValidationError -from django.core.validators import (MaxValueValidator, MinValueValidator, - URLValidator) +from django.core.validators import MaxValueValidator, MinValueValidator, URLValidator from django.db import models, transaction from django.db.models.signals import post_delete, post_save from django.db.utils import IntegrityError, OperationalError, ProgrammingError @@ -66,6 +65,7 @@ class MetaMixin(models.Model): class Meta: """Meta options for MetaMixin.""" + abstract = True updated = models.DateTimeField( @@ -88,7 +88,13 @@ class BaseURLValidator(URLValidator): super().__init__(schemes, **kwargs) # Override default host_re value - allow optional tld regex - self.host_re = '(' + self.hostname_re + self.domain_re + f'({self.tld_re})?' + '|localhost)' + self.host_re = ( + '(' + + self.hostname_re + + self.domain_re + + f'({self.tld_re})?' + + '|localhost)' + ) def __call__(self, value): """Make sure empty values pass.""" @@ -130,7 +136,8 @@ class ProjectCode(InvenTree.models.MetadataMixin, models.Model): responsible = models.ForeignKey( users.models.Owner, on_delete=models.SET_NULL, - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Responsible'), help_text=_('User or group responsible for this project'), related_name='project_codes', @@ -226,16 +233,18 @@ class BaseInvenTreeSetting(models.Model): missing_keys = set(settings_keys) - set(existing_keys) if len(missing_keys) > 0: - logger.info("Building %s default values for %s", len(missing_keys), str(cls)) + logger.info( + "Building %s default values for %s", len(missing_keys), str(cls) + ) cls.objects.bulk_create([ - cls( - key=key, - value=cls.get_setting_default(key), - **kwargs - ) for key in missing_keys if not key.startswith('_') + cls(key=key, value=cls.get_setting_default(key), **kwargs) + for key in missing_keys + if not key.startswith('_') ]) except Exception as exc: - logger.exception("Failed to build default values for %s (%s)", str(cls), str(type(exc))) + logger.exception( + "Failed to build default values for %s (%s)", str(cls), str(type(exc)) + ) pass cache.set(cache_key, True, timeout=3600) @@ -249,7 +258,9 @@ class BaseInvenTreeSetting(models.Model): kwargs: Keyword arguments to pass to the function """ # Get action - setting = self.get_setting_definition(self.key, *args, **{**self.get_filters_for_instance(), **kwargs}) + setting = self.get_setting_definition( + self.key, *args, **{**self.get_filters_for_instance(), **kwargs} + ) settings_fnc = setting.get(reference, None) # Execute if callable @@ -259,7 +270,9 @@ class BaseInvenTreeSetting(models.Model): @property def cache_key(self): """Generate a unique cache key for this settings object""" - return self.__class__.create_cache_key(self.key, **self.get_filters_for_instance()) + return self.__class__.create_cache_key( + self.key, **self.get_filters_for_instance() + ) def save_to_cache(self): """Save this setting object to cache""" @@ -272,11 +285,7 @@ class BaseInvenTreeSetting(models.Model): logger.debug("Saving setting '%s' to cache", ckey) try: - cache.set( - ckey, - self, - timeout=3600 - ) + cache.set(ckey, self, timeout=3600) except TypeError: # Some characters cause issues with caching; ignore and move on pass @@ -300,14 +309,28 @@ class BaseInvenTreeSetting(models.Model): @classmethod def get_filters(cls, **kwargs): """Enable to filter by other kwargs defined in cls.extra_unique_fields""" - return {key: value for key, value in kwargs.items() if key in cls.extra_unique_fields} + return { + key: value + for key, value in kwargs.items() + if key in cls.extra_unique_fields + } def get_filters_for_instance(self): """Enable to filter by other fields defined in self.extra_unique_fields""" - return {key: getattr(self, key, None) for key in self.extra_unique_fields if hasattr(self, key)} + return { + key: getattr(self, key, None) + for key in self.extra_unique_fields + if hasattr(self, key) + } @classmethod - def all_settings(cls, *, exclude_hidden=False, settings_definition: Union[Dict[str, SettingsKeyType], None] = None, **kwargs): + def all_settings( + cls, + *, + exclude_hidden=False, + settings_definition: Union[Dict[str, SettingsKeyType], None] = None, + **kwargs, + ): """Return a list of "all" defined settings. This performs a single database lookup, @@ -339,7 +362,7 @@ class BaseInvenTreeSetting(models.Model): settings[key.upper()] = cls( key=key.upper(), value=cls.get_setting_default(key, **filters), - **filters + **filters, ) # remove any hidden settings @@ -363,14 +386,24 @@ class BaseInvenTreeSetting(models.Model): return settings @classmethod - def allValues(cls, *, exclude_hidden=False, settings_definition: Union[Dict[str, SettingsKeyType], None] = None, **kwargs): + def allValues( + cls, + *, + exclude_hidden=False, + settings_definition: Union[Dict[str, SettingsKeyType], None] = None, + **kwargs, + ): """Return a dict of "all" defined global settings. This performs a single database lookup, and then any settings which are not *in* the database are assigned their default values """ - all_settings = cls.all_settings(exclude_hidden=exclude_hidden, settings_definition=settings_definition, **kwargs) + all_settings = cls.all_settings( + exclude_hidden=exclude_hidden, + settings_definition=settings_definition, + **kwargs, + ) settings: Dict[str, Any] = {} @@ -380,14 +413,24 @@ class BaseInvenTreeSetting(models.Model): return settings @classmethod - def check_all_settings(cls, *, exclude_hidden=False, settings_definition: Union[Dict[str, SettingsKeyType], None] = None, **kwargs): + def check_all_settings( + cls, + *, + exclude_hidden=False, + settings_definition: Union[Dict[str, SettingsKeyType], None] = None, + **kwargs, + ): """Check if all required settings are set by definition. Returns: is_valid: Are all required settings defined missing_settings: List of all settings that are missing (empty if is_valid is 'True') """ - all_settings = cls.all_settings(exclude_hidden=exclude_hidden, settings_definition=settings_definition, **kwargs) + all_settings = cls.all_settings( + exclude_hidden=exclude_hidden, + settings_definition=settings_definition, + **kwargs, + ) missing_settings: List[str] = [] @@ -496,7 +539,6 @@ class BaseInvenTreeSetting(models.Model): filters = { 'key__iexact': key, - # Optionally filter by other keys **cls.get_filters(**kwargs), } @@ -539,9 +581,13 @@ class BaseInvenTreeSetting(models.Model): # Setting does not exist! (Try to create it) if not setting: - # Prevent creation of new settings objects when importing data - if InvenTree.ready.isImportingData() or not InvenTree.ready.canAppAccessDatabase(allow_test=True, allow_shell=True): + if ( + InvenTree.ready.isImportingData() + or not InvenTree.ready.canAppAccessDatabase( + allow_test=True, allow_shell=True + ) + ): create = False if create: @@ -549,11 +595,7 @@ class BaseInvenTreeSetting(models.Model): default_value = cls.get_setting_default(key, **kwargs) - setting = cls( - key=key, - value=default_value, - **kwargs - ) + setting = cls(key=key, value=default_value, **kwargs) try: # Wrap this statement in "atomic", so it can be rolled back if it fails @@ -620,7 +662,6 @@ class BaseInvenTreeSetting(models.Model): filters = { 'key__iexact': key, - # Optionally filter by other keys **cls.get_filters(**kwargs), } @@ -628,7 +669,6 @@ class BaseInvenTreeSetting(models.Model): try: setting = cls.objects.get(**filters) except cls.DoesNotExist: - if create: setting = cls(key=key, **kwargs) else: @@ -648,36 +688,62 @@ class BaseInvenTreeSetting(models.Model): # Likely a race condition has caused a duplicate entry to be created if attempts > 0: # Try again - logger.info("Duplicate setting key '%s' for %s - trying again", key, str(cls)) - cls.set_setting(key, value, change_user, create=create, attempts=attempts - 1, **kwargs) + logger.info( + "Duplicate setting key '%s' for %s - trying again", key, str(cls) + ) + cls.set_setting( + key, + value, + change_user, + create=create, + attempts=attempts - 1, + **kwargs, + ) except Exception as exc: # Some other error - logger.exception("Error setting setting '%s' for %s: %s", key, str(cls), str(type(exc))) + logger.exception( + "Error setting setting '%s' for %s: %s", key, str(cls), str(type(exc)) + ) pass - key = models.CharField(max_length=50, blank=False, unique=False, help_text=_('Settings key (must be unique - case insensitive)')) + key = models.CharField( + max_length=50, + blank=False, + unique=False, + help_text=_('Settings key (must be unique - case insensitive)'), + ) - value = models.CharField(max_length=2000, blank=True, unique=False, help_text=_('Settings value')) + value = models.CharField( + max_length=2000, blank=True, unique=False, help_text=_('Settings value') + ) @property def name(self): """Return name for setting.""" - return self.__class__.get_setting_name(self.key, **self.get_filters_for_instance()) + return self.__class__.get_setting_name( + self.key, **self.get_filters_for_instance() + ) @property def default_value(self): """Return default_value for setting.""" - return self.__class__.get_setting_default(self.key, **self.get_filters_for_instance()) + return self.__class__.get_setting_default( + self.key, **self.get_filters_for_instance() + ) @property def description(self): """Return description for setting.""" - return self.__class__.get_setting_description(self.key, **self.get_filters_for_instance()) + return self.__class__.get_setting_description( + self.key, **self.get_filters_for_instance() + ) @property def units(self): """Return units for setting.""" - return self.__class__.get_setting_units(self.key, **self.get_filters_for_instance()) + return self.__class__.get_setting_units( + self.key, **self.get_filters_for_instance() + ) def clean(self): """If a validator (or multiple validators) are defined for a particular setting key, run them against the 'value' field.""" @@ -690,7 +756,9 @@ class BaseInvenTreeSetting(models.Model): elif self.is_bool(): self.value = self.as_bool() - validator = self.__class__.get_setting_validator(self.key, **self.get_filters_for_instance()) + validator = self.__class__.get_setting_validator( + self.key, **self.get_filters_for_instance() + ) if validator is not None: self.run_validator(validator) @@ -714,20 +782,15 @@ class BaseInvenTreeSetting(models.Model): # Coerce into either "True" or "False" value = InvenTree.helpers.str2bool(value) else: - raise ValidationError({ - 'value': _('Value must be a boolean value') - }) + raise ValidationError({'value': _('Value must be a boolean value')}) # Integer validator if validator is int: - try: # Coerce into an integer value value = int(value) except (ValueError, TypeError): - raise ValidationError({ - 'value': _('Value must be an integer value'), - }) + raise ValidationError({'value': _('Value must be an integer value')}) # If a list of validators is supplied, iterate through each one if type(validator) in [list, tuple]: @@ -755,7 +818,6 @@ class BaseInvenTreeSetting(models.Model): filters = { 'key__iexact': self.key, - # Optionally filter by other keys **self.get_filters_for_instance(), } @@ -772,7 +834,9 @@ class BaseInvenTreeSetting(models.Model): 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, **self.get_filters_for_instance()) + return self.__class__.get_setting_choices( + self.key, **self.get_filters_for_instance() + ) def valid_options(self): """Return a list of valid options for this setting.""" @@ -785,7 +849,12 @@ class BaseInvenTreeSetting(models.Model): def is_choice(self): """Check if this setting is a "choice" field.""" - return self.__class__.get_setting_choices(self.key, **self.get_filters_for_instance()) is not None + return ( + self.__class__.get_setting_choices( + self.key, **self.get_filters_for_instance() + ) + is not None + ) def as_choice(self): """Render this setting as the "display" value of a choice field. @@ -812,7 +881,9 @@ class BaseInvenTreeSetting(models.Model): def model_name(self): """Return the model name associated with this setting.""" - setting = self.get_setting_definition(self.key, **self.get_filters_for_instance()) + setting = self.get_setting_definition( + self.key, **self.get_filters_for_instance() + ) return setting.get('model', None) @@ -832,19 +903,31 @@ class BaseInvenTreeSetting(models.Model): try: (app, mdl) = model_name.strip().split('.') except ValueError: - logger.exception("Invalid 'model' parameter for setting '%s': '%s'", self.key, model_name) + logger.exception( + "Invalid 'model' parameter for setting '%s': '%s'", self.key, model_name + ) return None app_models = apps.all_models.get(app, None) if app_models is None: - logger.error("Error retrieving model class '%s' for setting '%s' - no app named '%s'", model_name, self.key, app) + logger.error( + "Error retrieving model class '%s' for setting '%s' - no app named '%s'", + model_name, + self.key, + app, + ) return None model = app_models.get(mdl, None) if model is None: - logger.error("Error retrieving model class '%s' for setting '%s' - no model named '%s'", model_name, self.key, mdl) + logger.error( + "Error retrieving model class '%s' for setting '%s' - no model named '%s'", + model_name, + self.key, + mdl, + ) return None # Looks like we have found a model! @@ -867,7 +950,9 @@ class BaseInvenTreeSetting(models.Model): 'auth.group': 'api-group-list', } - model_table = f'{model_class._meta.app_label}.{model_class._meta.model_name}' + model_table = ( + f'{model_class._meta.app_label}.{model_class._meta.model_name}' + ) if url := hardcoded_models[model_table]: return reverse(url) @@ -876,7 +961,9 @@ class BaseInvenTreeSetting(models.Model): def is_bool(self): """Check if this setting is required to be a boolean value.""" - validator = self.__class__.get_setting_validator(self.key, **self.get_filters_for_instance()) + validator = self.__class__.get_setting_validator( + self.key, **self.get_filters_for_instance() + ) return self.__class__.validator_is_bool(validator) @@ -912,9 +999,11 @@ class BaseInvenTreeSetting(models.Model): return False - def is_int(self,): + def is_int(self): """Check if the setting is required to be an integer value.""" - validator = self.__class__.get_setting_validator(self.key, **self.get_filters_for_instance()) + validator = self.__class__.get_setting_validator( + self.key, **self.get_filters_for_instance() + ) return self.__class__.validator_is_int(validator) @@ -1004,13 +1093,12 @@ def currency_exchange_plugins(): """Return a set of plugin choices which can be used for currency exchange""" try: from plugin import registry + plugs = registry.with_mixin('currencyexchange', active=True) except Exception: plugs = [] - return [ - ('', _('No plugin')), - ] + [(plug.slug, plug.human_name) for plug in plugs] + return [('', _('No plugin'))] + [(plug.slug, plug.human_name) for plug in plugs] def update_exchange_rates(setting): @@ -1073,49 +1161,44 @@ class InvenTreeSetting(BaseInvenTreeSetting): """ SETTINGS = { - 'SERVER_RESTART_REQUIRED': { 'name': _('Restart required'), - 'description': _('A setting has been changed which requires a server restart'), + 'description': _( + 'A setting has been changed which requires a server restart' + ), 'default': False, 'validator': bool, 'hidden': True, }, - '_PENDING_MIGRATIONS': { 'name': _('Pending migrations'), 'description': _('Number of pending database migrations'), 'default': 0, 'validator': int, }, - 'INVENTREE_INSTANCE': { 'name': _('Server Instance Name'), 'default': 'InvenTree', 'description': _('String descriptor for the server instance'), 'after_save': update_instance_name, }, - 'INVENTREE_INSTANCE_TITLE': { 'name': _('Use instance name'), 'description': _('Use the instance name in the title-bar'), 'validator': bool, 'default': False, }, - 'INVENTREE_RESTRICT_ABOUT': { 'name': _('Restrict showing `about`'), 'description': _('Show the `about` modal only to superusers'), 'validator': bool, 'default': False, }, - 'INVENTREE_COMPANY_NAME': { 'name': _('Company name'), 'description': _('Internal company name'), 'default': 'My company name', }, - 'INVENTREE_BASE_URL': { 'name': _('Base URL'), 'description': _('Base URL for server instance'), @@ -1123,7 +1206,6 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'default': '', 'after_save': update_instance_url, }, - 'INVENTREE_DEFAULT_CURRENCY': { 'name': _('Default Currency'), 'description': _('Select base currency for pricing calculations'), @@ -1131,597 +1213,534 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'choices': CURRENCY_CHOICES, 'after_save': update_exchange_rates, }, - 'CURRENCY_UPDATE_INTERVAL': { 'name': _('Currency Update Interval'), - 'description': _('How often to update exchange rates (set to zero to disable)'), + 'description': _( + 'How often to update exchange rates (set to zero to disable)' + ), 'default': 1, 'units': _('days'), - 'validator': [ - int, - MinValueValidator(0), - ], + 'validator': [int, MinValueValidator(0)], }, - 'CURRENCY_UPDATE_PLUGIN': { 'name': _('Currency Update Plugin'), 'description': _('Currency update plugin to use'), 'choices': currency_exchange_plugins, - 'default': 'inventreecurrencyexchange' + 'default': 'inventreecurrencyexchange', }, - 'INVENTREE_DOWNLOAD_FROM_URL': { 'name': _('Download from URL'), - 'description': _('Allow download of remote images and files from external URL'), + 'description': _( + 'Allow download of remote images and files from external URL' + ), 'validator': bool, 'default': False, }, - 'INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE': { 'name': _('Download Size Limit'), 'description': _('Maximum allowable download size for remote image'), 'units': 'MB', 'default': 1, - 'validator': [ - int, - MinValueValidator(1), - MaxValueValidator(25), - ] + 'validator': [int, MinValueValidator(1), MaxValueValidator(25)], }, - 'INVENTREE_DOWNLOAD_FROM_URL_USER_AGENT': { 'name': _('User-agent used to download from URL'), - 'description': _('Allow to override the user-agent used to download images and files from external URL (leave blank for the default)'), + 'description': _( + 'Allow to override the user-agent used to download images and files from external URL (leave blank for the default)' + ), 'default': '', }, - 'INVENTREE_STRICT_URLS': { 'name': _('Strict URL Validation'), 'description': _('Require schema specification when validating URLs'), 'validator': bool, 'default': True, }, - 'INVENTREE_REQUIRE_CONFIRM': { 'name': _('Require confirm'), 'description': _('Require explicit user confirmation for certain action.'), 'validator': bool, 'default': True, }, - 'INVENTREE_TREE_DEPTH': { 'name': _('Tree Depth'), - 'description': _('Default tree depth for treeview. Deeper levels can be lazy loaded as they are needed.'), + 'description': _( + 'Default tree depth for treeview. Deeper levels can be lazy loaded as they are needed.' + ), 'default': 1, - 'validator': [ - int, - MinValueValidator(0), - ] + 'validator': [int, MinValueValidator(0)], }, - 'INVENTREE_UPDATE_CHECK_INTERVAL': { 'name': _('Update Check Interval'), 'description': _('How often to check for updates (set to zero to disable)'), - 'validator': [ - int, - MinValueValidator(0), - ], + 'validator': [int, MinValueValidator(0)], 'default': 7, 'units': _('days'), }, - 'INVENTREE_BACKUP_ENABLE': { 'name': _('Automatic Backup'), 'description': _('Enable automatic backup of database and media files'), 'validator': bool, 'default': False, }, - 'INVENTREE_BACKUP_DAYS': { 'name': _('Auto Backup Interval'), 'description': _('Specify number of days between automated backup events'), - 'validator': [ - int, - MinValueValidator(1), - ], + 'validator': [int, MinValueValidator(1)], 'default': 1, 'units': _('days'), }, - 'INVENTREE_DELETE_TASKS_DAYS': { 'name': _('Task Deletion Interval'), - 'description': _('Background task results will be deleted after specified number of days'), + 'description': _( + 'Background task results will be deleted after specified number of days' + ), 'default': 30, 'units': _('days'), - 'validator': [ - int, - MinValueValidator(7), - ] + 'validator': [int, MinValueValidator(7)], }, - 'INVENTREE_DELETE_ERRORS_DAYS': { 'name': _('Error Log Deletion Interval'), - 'description': _('Error logs will be deleted after specified number of days'), + 'description': _( + 'Error logs will be deleted after specified number of days' + ), 'default': 30, 'units': _('days'), - 'validator': [ - int, - MinValueValidator(7) - ] + 'validator': [int, MinValueValidator(7)], }, - 'INVENTREE_DELETE_NOTIFICATIONS_DAYS': { 'name': _('Notification Deletion Interval'), - 'description': _('User notifications will be deleted after specified number of days'), + 'description': _( + 'User notifications will be deleted after specified number of days' + ), 'default': 30, 'units': _('days'), - 'validator': [ - int, - MinValueValidator(7), - ] + 'validator': [int, MinValueValidator(7)], }, - 'BARCODE_ENABLE': { 'name': _('Barcode Support'), 'description': _('Enable barcode scanner support in the web interface'), 'default': True, 'validator': bool, }, - 'BARCODE_INPUT_DELAY': { 'name': _('Barcode Input Delay'), 'description': _('Barcode input processing delay time'), 'default': 50, - 'validator': [ - int, - MinValueValidator(1), - ], + 'validator': [int, MinValueValidator(1)], 'units': 'ms', }, - 'BARCODE_WEBCAM_SUPPORT': { 'name': _('Barcode Webcam Support'), 'description': _('Allow barcode scanning via webcam in browser'), 'default': True, 'validator': bool, }, - 'PART_ENABLE_REVISION': { 'name': _('Part Revisions'), 'description': _('Enable revision field for Part'), 'validator': bool, 'default': True, }, - 'PART_IPN_REGEX': { 'name': _('IPN Regex'), - 'description': _('Regular expression pattern for matching Part IPN') + 'description': _('Regular expression pattern for matching Part IPN'), }, - 'PART_ALLOW_DUPLICATE_IPN': { 'name': _('Allow Duplicate IPN'), 'description': _('Allow multiple parts to share the same IPN'), 'default': True, 'validator': bool, }, - 'PART_ALLOW_EDIT_IPN': { 'name': _('Allow Editing IPN'), 'description': _('Allow changing the IPN value while editing a part'), 'default': True, 'validator': bool, }, - 'PART_COPY_BOM': { 'name': _('Copy Part BOM Data'), 'description': _('Copy BOM data by default when duplicating a part'), 'default': True, 'validator': bool, }, - 'PART_COPY_PARAMETERS': { 'name': _('Copy Part Parameter Data'), 'description': _('Copy parameter data by default when duplicating a part'), 'default': True, 'validator': bool, }, - 'PART_COPY_TESTS': { 'name': _('Copy Part Test Data'), 'description': _('Copy test data by default when duplicating a part'), 'default': True, - 'validator': bool + 'validator': bool, }, - 'PART_CATEGORY_PARAMETERS': { 'name': _('Copy Category Parameter Templates'), 'description': _('Copy category parameter templates when creating a part'), 'default': True, - 'validator': bool + 'validator': bool, }, - 'PART_TEMPLATE': { 'name': _('Template'), 'description': _('Parts are templates by default'), 'default': False, 'validator': bool, }, - 'PART_ASSEMBLY': { 'name': _('Assembly'), 'description': _('Parts can be assembled from other components by default'), 'default': False, 'validator': bool, }, - 'PART_COMPONENT': { 'name': _('Component'), 'description': _('Parts can be used as sub-components by default'), 'default': True, 'validator': bool, }, - 'PART_PURCHASEABLE': { 'name': _('Purchaseable'), 'description': _('Parts are purchaseable by default'), 'default': True, 'validator': bool, }, - 'PART_SALABLE': { 'name': _('Salable'), 'description': _('Parts are salable by default'), 'default': False, 'validator': bool, }, - 'PART_TRACKABLE': { 'name': _('Trackable'), 'description': _('Parts are trackable by default'), 'default': False, 'validator': bool, }, - 'PART_VIRTUAL': { 'name': _('Virtual'), 'description': _('Parts are virtual by default'), 'default': False, 'validator': bool, }, - 'PART_SHOW_IMPORT': { 'name': _('Show Import in Views'), 'description': _('Display the import wizard in some part views'), 'default': False, 'validator': bool, }, - 'PART_SHOW_RELATED': { 'name': _('Show related parts'), 'description': _('Display related parts for a part'), 'default': True, 'validator': bool, }, - 'PART_CREATE_INITIAL': { 'name': _('Initial Stock Data'), 'description': _('Allow creation of initial stock when adding a new part'), 'default': False, 'validator': bool, }, - 'PART_CREATE_SUPPLIER': { 'name': _('Initial Supplier Data'), - 'description': _('Allow creation of initial supplier data when adding a new part'), + 'description': _( + 'Allow creation of initial supplier data when adding a new part' + ), 'default': True, 'validator': bool, }, - 'PART_NAME_FORMAT': { 'name': _('Part Name Display Format'), 'description': _('Format to display the part name'), 'default': "{{ part.IPN if part.IPN }}{{ ' | ' if part.IPN }}{{ part.name }}{{ ' | ' if part.revision }}" - "{{ part.revision if part.revision }}", - 'validator': InvenTree.validators.validate_part_name_format + "{{ part.revision if part.revision }}", + 'validator': InvenTree.validators.validate_part_name_format, }, - 'PART_CATEGORY_DEFAULT_ICON': { 'name': _('Part Category Default Icon'), 'description': _('Part category default icon (empty means no icon)'), 'default': '', }, - 'PART_PARAMETER_ENFORCE_UNITS': { 'name': _('Enforce Parameter Units'), - 'description': _('If units are provided, parameter values must match the specified units'), + 'description': _( + 'If units are provided, parameter values must match the specified units' + ), 'default': True, 'validator': bool, }, - 'PRICING_DECIMAL_PLACES_MIN': { 'name': _('Minimum Pricing Decimal Places'), - 'description': _('Minimum number of decimal places to display when rendering pricing data'), + 'description': _( + 'Minimum number of decimal places to display when rendering pricing data' + ), 'default': 0, - 'validator': [ - int, - MinValueValidator(0), - MaxValueValidator(4), - ] + 'validator': [int, MinValueValidator(0), MaxValueValidator(4)], }, - 'PRICING_DECIMAL_PLACES': { 'name': _('Maximum Pricing Decimal Places'), - 'description': _('Maximum number of decimal places to display when rendering pricing data'), + 'description': _( + 'Maximum number of decimal places to display when rendering pricing data' + ), 'default': 6, - 'validator': [ - int, - MinValueValidator(2), - MaxValueValidator(6) - ] + 'validator': [int, MinValueValidator(2), MaxValueValidator(6)], }, - 'PRICING_USE_SUPPLIER_PRICING': { 'name': _('Use Supplier Pricing'), - 'description': _('Include supplier price breaks in overall pricing calculations'), + 'description': _( + 'Include supplier price breaks in overall pricing calculations' + ), 'default': True, 'validator': bool, }, - 'PRICING_PURCHASE_HISTORY_OVERRIDES_SUPPLIER': { 'name': _('Purchase History Override'), - 'description': _('Historical purchase order pricing overrides supplier price breaks'), + 'description': _( + 'Historical purchase order pricing overrides supplier price breaks' + ), 'default': False, 'validator': bool, }, - 'PRICING_USE_STOCK_PRICING': { 'name': _('Use Stock Item Pricing'), - 'description': _('Use pricing from manually entered stock data for pricing calculations'), + 'description': _( + 'Use pricing from manually entered stock data for pricing calculations' + ), 'default': True, 'validator': bool, }, - 'PRICING_STOCK_ITEM_AGE_DAYS': { 'name': _('Stock Item Pricing Age'), - 'description': _('Exclude stock items older than this number of days from pricing calculations'), + 'description': _( + 'Exclude stock items older than this number of days from pricing calculations' + ), 'default': 0, 'units': _('days'), - 'validator': [ - int, - MinValueValidator(0), - ] + 'validator': [int, MinValueValidator(0)], }, - 'PRICING_USE_VARIANT_PRICING': { 'name': _('Use Variant Pricing'), 'description': _('Include variant pricing in overall pricing calculations'), 'default': True, 'validator': bool, }, - 'PRICING_ACTIVE_VARIANTS': { 'name': _('Active Variants Only'), - 'description': _('Only use active variant parts for calculating variant pricing'), + 'description': _( + 'Only use active variant parts for calculating variant pricing' + ), 'default': False, 'validator': bool, }, - 'PRICING_UPDATE_DAYS': { 'name': _('Pricing Rebuild Interval'), - 'description': _('Number of days before part pricing is automatically updated'), + 'description': _( + 'Number of days before part pricing is automatically updated' + ), 'units': _('days'), 'default': 30, - 'validator': [ - int, - MinValueValidator(10), - ] + 'validator': [int, MinValueValidator(10)], }, - 'PART_INTERNAL_PRICE': { 'name': _('Internal Prices'), 'description': _('Enable internal prices for parts'), 'default': False, - 'validator': bool + 'validator': bool, }, - 'PART_BOM_USE_INTERNAL_PRICE': { 'name': _('Internal Price Override'), - 'description': _('If available, internal prices override price range calculations'), + 'description': _( + 'If available, internal prices override price range calculations' + ), 'default': False, - 'validator': bool + 'validator': bool, }, - 'LABEL_ENABLE': { 'name': _('Enable label printing'), 'description': _('Enable label printing from the web interface'), 'default': True, 'validator': bool, }, - 'LABEL_DPI': { 'name': _('Label Image DPI'), - 'description': _('DPI resolution when generating image files to supply to label printing plugins'), + 'description': _( + 'DPI resolution when generating image files to supply to label printing plugins' + ), 'default': 300, - 'validator': [ - int, - MinValueValidator(100), - ] + 'validator': [int, MinValueValidator(100)], }, - 'REPORT_ENABLE': { 'name': _('Enable Reports'), 'description': _('Enable generation of reports'), 'default': False, 'validator': bool, }, - 'REPORT_DEBUG_MODE': { 'name': _('Debug Mode'), 'description': _('Generate reports in debug mode (HTML output)'), 'default': False, 'validator': bool, }, - 'REPORT_DEFAULT_PAGE_SIZE': { 'name': _('Page Size'), 'description': _('Default page size for PDF reports'), 'default': 'A4', 'choices': report.helpers.report_page_size_options, }, - 'REPORT_ENABLE_TEST_REPORT': { 'name': _('Enable Test Reports'), 'description': _('Enable generation of test reports'), 'default': True, 'validator': bool, }, - 'REPORT_ATTACH_TEST_REPORT': { 'name': _('Attach Test Reports'), - 'description': _('When printing a Test Report, attach a copy of the Test Report to the associated Stock Item'), + 'description': _( + 'When printing a Test Report, attach a copy of the Test Report to the associated Stock Item' + ), 'default': False, 'validator': bool, }, - 'SERIAL_NUMBER_GLOBALLY_UNIQUE': { 'name': _('Globally Unique Serials'), 'description': _('Serial numbers for stock items must be globally unique'), 'default': False, 'validator': bool, }, - 'SERIAL_NUMBER_AUTOFILL': { 'name': _('Autofill Serial Numbers'), 'description': _('Autofill serial numbers in forms'), 'default': False, 'validator': bool, }, - 'STOCK_DELETE_DEPLETED_DEFAULT': { 'name': _('Delete Depleted Stock'), - 'description': _('Determines default behaviour when a stock item is depleted'), + 'description': _( + 'Determines default behaviour when a stock item is depleted' + ), 'default': True, 'validator': bool, }, - 'STOCK_BATCH_CODE_TEMPLATE': { 'name': _('Batch Code Template'), - 'description': _('Template for generating default batch codes for stock items'), + 'description': _( + 'Template for generating default batch codes for stock items' + ), 'default': '', }, - 'STOCK_ENABLE_EXPIRY': { 'name': _('Stock Expiry'), 'description': _('Enable stock expiry functionality'), 'default': False, 'validator': bool, }, - 'STOCK_ALLOW_EXPIRED_SALE': { 'name': _('Sell Expired Stock'), 'description': _('Allow sale of expired stock'), 'default': False, 'validator': bool, }, - 'STOCK_STALE_DAYS': { 'name': _('Stock Stale Time'), - 'description': _('Number of days stock items are considered stale before expiring'), + 'description': _( + 'Number of days stock items are considered stale before expiring' + ), 'default': 0, 'units': _('days'), 'validator': [int], }, - 'STOCK_ALLOW_EXPIRED_BUILD': { 'name': _('Build Expired Stock'), 'description': _('Allow building with expired stock'), 'default': False, 'validator': bool, }, - 'STOCK_OWNERSHIP_CONTROL': { 'name': _('Stock Ownership Control'), 'description': _('Enable ownership control over stock locations and items'), 'default': False, 'validator': bool, }, - 'STOCK_LOCATION_DEFAULT_ICON': { 'name': _('Stock Location Default Icon'), 'description': _('Stock location default icon (empty means no icon)'), 'default': '', }, - 'STOCK_SHOW_INSTALLED_ITEMS': { 'name': _('Show Installed Stock Items'), 'description': _('Display installed stock items in stock tables'), 'default': False, 'validator': bool, }, - 'BUILDORDER_REFERENCE_PATTERN': { 'name': _('Build Order Reference Pattern'), - 'description': _('Required pattern for generating Build Order reference field'), + 'description': _( + 'Required pattern for generating Build Order reference field' + ), 'default': 'BO-{ref:04d}', 'validator': build.validators.validate_build_order_reference_pattern, }, - 'RETURNORDER_ENABLED': { 'name': _('Enable Return Orders'), 'description': _('Enable return order functionality in the user interface'), 'validator': bool, 'default': False, }, - 'RETURNORDER_REFERENCE_PATTERN': { 'name': _('Return Order Reference Pattern'), - 'description': _('Required pattern for generating Return Order reference field'), + 'description': _( + 'Required pattern for generating Return Order reference field' + ), 'default': 'RMA-{ref:04d}', 'validator': order.validators.validate_return_order_reference_pattern, }, - 'RETURNORDER_EDIT_COMPLETED_ORDERS': { 'name': _('Edit Completed Return Orders'), - 'description': _('Allow editing of return orders after they have been completed'), + 'description': _( + 'Allow editing of return orders after they have been completed' + ), 'default': False, 'validator': bool, }, - 'SALESORDER_REFERENCE_PATTERN': { 'name': _('Sales Order Reference Pattern'), - 'description': _('Required pattern for generating Sales Order reference field'), + 'description': _( + 'Required pattern for generating Sales Order reference field' + ), 'default': 'SO-{ref:04d}', 'validator': order.validators.validate_sales_order_reference_pattern, }, - 'SALESORDER_DEFAULT_SHIPMENT': { 'name': _('Sales Order Default Shipment'), 'description': _('Enable creation of default shipment with sales orders'), 'default': False, 'validator': bool, }, - 'SALESORDER_EDIT_COMPLETED_ORDERS': { 'name': _('Edit Completed Sales Orders'), - 'description': _('Allow editing of sales orders after they have been shipped or completed'), + 'description': _( + 'Allow editing of sales orders after they have been shipped or completed' + ), 'default': False, 'validator': bool, }, - 'PURCHASEORDER_REFERENCE_PATTERN': { 'name': _('Purchase Order Reference Pattern'), - 'description': _('Required pattern for generating Purchase Order reference field'), + 'description': _( + 'Required pattern for generating Purchase Order reference field' + ), 'default': 'PO-{ref:04d}', 'validator': order.validators.validate_purchase_order_reference_pattern, }, - 'PURCHASEORDER_EDIT_COMPLETED_ORDERS': { 'name': _('Edit Completed Purchase Orders'), - 'description': _('Allow editing of purchase orders after they have been shipped or completed'), + 'description': _( + 'Allow editing of purchase orders after they have been shipped or completed' + ), 'default': False, 'validator': bool, }, - # login / SSO 'LOGIN_ENABLE_PWD_FORGOT': { 'name': _('Enable password forgot'), @@ -1729,85 +1748,82 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'default': True, 'validator': bool, }, - 'LOGIN_ENABLE_REG': { 'name': _('Enable registration'), 'description': _('Enable self-registration for users on the login pages'), 'default': False, 'validator': bool, }, - 'LOGIN_ENABLE_SSO': { 'name': _('Enable SSO'), 'description': _('Enable SSO on the login pages'), 'default': False, 'validator': bool, }, - 'LOGIN_ENABLE_SSO_REG': { 'name': _('Enable SSO registration'), - 'description': _('Enable self-registration via SSO for users on the login pages'), + 'description': _( + 'Enable self-registration via SSO for users on the login pages' + ), 'default': False, 'validator': bool, }, - 'LOGIN_MAIL_REQUIRED': { 'name': _('Email required'), 'description': _('Require user to supply mail on signup'), 'default': False, 'validator': bool, }, - 'LOGIN_SIGNUP_SSO_AUTO': { 'name': _('Auto-fill SSO users'), - 'description': _('Automatically fill out user-details from SSO account-data'), + 'description': _( + 'Automatically fill out user-details from SSO account-data' + ), 'default': True, 'validator': bool, }, - 'LOGIN_SIGNUP_MAIL_TWICE': { 'name': _('Mail twice'), 'description': _('On signup ask users twice for their mail'), 'default': False, 'validator': bool, }, - 'LOGIN_SIGNUP_PWD_TWICE': { 'name': _('Password twice'), 'description': _('On signup ask users twice for their password'), 'default': True, 'validator': bool, }, - 'LOGIN_SIGNUP_MAIL_RESTRICTION': { 'name': _('Allowed domains'), - 'description': _('Restrict signup to certain domains (comma-separated, starting with @)'), + 'description': _( + 'Restrict signup to certain domains (comma-separated, starting with @)' + ), 'default': '', 'before_save': validate_email_domains, }, - 'SIGNUP_GROUP': { 'name': _('Group on signup'), 'description': _('Group to which new users are assigned on registration'), 'default': '', - 'choices': settings_group_options + 'choices': settings_group_options, }, - 'LOGIN_ENFORCE_MFA': { 'name': _('Enforce MFA'), 'description': _('Users must use multifactor security.'), 'default': False, 'validator': bool, }, - 'PLUGIN_ON_STARTUP': { 'name': _('Check plugins on startup'), - 'description': _('Check that all plugins are installed on startup - enable in container environments'), - 'default': str(os.getenv('INVENTREE_DOCKER', False)).lower() in ['1', 'true'], + 'description': _( + 'Check that all plugins are installed on startup - enable in container environments' + ), + 'default': str(os.getenv('INVENTREE_DOCKER', False)).lower() + in ['1', 'true'], 'validator': bool, 'requires_restart': True, }, - # Settings for plugin mixin features 'ENABLE_PLUGINS_URL': { 'name': _('Enable URL integration'), @@ -1816,7 +1832,6 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': bool, 'after_save': reload_plugin_registry, }, - 'ENABLE_PLUGINS_NAVIGATION': { 'name': _('Enable navigation integration'), 'description': _('Enable plugins to integrate into navigation'), @@ -1824,7 +1839,6 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': bool, 'after_save': reload_plugin_registry, }, - 'ENABLE_PLUGINS_APP': { 'name': _('Enable app integration'), 'description': _('Enable plugins to add apps'), @@ -1832,7 +1846,6 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': bool, 'after_save': reload_plugin_registry, }, - 'ENABLE_PLUGINS_SCHEDULE': { 'name': _('Enable schedule integration'), 'description': _('Enable plugins to run scheduled tasks'), @@ -1840,7 +1853,6 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': bool, 'after_save': reload_plugin_registry, }, - 'ENABLE_PLUGINS_EVENTS': { 'name': _('Enable event integration'), 'description': _('Enable plugins to respond to internal events'), @@ -1848,55 +1860,51 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': bool, 'after_save': reload_plugin_registry, }, - "PROJECT_CODES_ENABLED": { 'name': _('Enable project codes'), 'description': _('Enable project codes for tracking projects'), 'default': False, 'validator': bool, }, - 'STOCKTAKE_ENABLE': { 'name': _('Stocktake Functionality'), - 'description': _('Enable stocktake functionality for recording stock levels and calculating stock value'), + 'description': _( + 'Enable stocktake functionality for recording stock levels and calculating stock value' + ), 'validator': bool, 'default': False, }, - 'STOCKTAKE_EXCLUDE_EXTERNAL': { 'name': _('Exclude External Locations'), - 'description': _('Exclude stock items in external locations from stocktake calculations'), + 'description': _( + 'Exclude stock items in external locations from stocktake calculations' + ), 'validator': bool, 'default': False, }, - 'STOCKTAKE_AUTO_DAYS': { 'name': _('Automatic Stocktake Period'), - 'description': _('Number of days between automatic stocktake recording (set to zero to disable)'), - 'validator': [ - int, - MinValueValidator(0), - ], + 'description': _( + 'Number of days between automatic stocktake recording (set to zero to disable)' + ), + 'validator': [int, MinValueValidator(0)], 'default': 0, }, - 'STOCKTAKE_DELETE_REPORT_DAYS': { 'name': _('Report Deletion Interval'), - 'description': _('Stocktake reports will be deleted after specified number of days'), + 'description': _( + 'Stocktake reports will be deleted after specified number of days' + ), 'default': 30, 'units': _('days'), - 'validator': [ - int, - MinValueValidator(7), - ] + 'validator': [int, MinValueValidator(7)], }, - 'DISPLAY_FULL_NAMES': { 'name': _('Display Users full names'), 'description': _('Display Users full names instead of usernames'), 'default': False, - 'validator': bool - } + 'validator': bool, + }, } typ = 'inventree' @@ -1926,7 +1934,9 @@ def label_printer_options(): printers = [] label_printer_plugins = registry.with_mixin('labels') if label_printer_plugins: - printers.extend([(p.slug, p.name + ' - ' + p.human_name) for p in label_printer_plugins]) + printers.extend([ + (p.slug, p.name + ' - ' + p.human_name) for p in label_printer_plugins + ]) return printers @@ -1943,315 +1953,288 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): ] SETTINGS = { - 'HOMEPAGE_HIDE_INACTIVE': { 'name': _('Hide inactive parts'), - 'description': _('Hide inactive parts in results displayed on the homepage'), + 'description': _( + 'Hide inactive parts in results displayed on the homepage' + ), 'default': True, 'validator': bool, }, - 'HOMEPAGE_PART_STARRED': { 'name': _('Show subscribed parts'), 'description': _('Show subscribed parts on the homepage'), 'default': True, 'validator': bool, }, - 'HOMEPAGE_CATEGORY_STARRED': { 'name': _('Show subscribed categories'), 'description': _('Show subscribed part categories on the homepage'), 'default': True, 'validator': bool, }, - 'HOMEPAGE_PART_LATEST': { 'name': _('Show latest parts'), 'description': _('Show latest parts on the homepage'), 'default': True, 'validator': bool, }, - 'HOMEPAGE_BOM_REQUIRES_VALIDATION': { 'name': _('Show unvalidated BOMs'), 'description': _('Show BOMs that await validation on the homepage'), 'default': False, 'validator': bool, }, - 'HOMEPAGE_STOCK_RECENT': { 'name': _('Show recent stock changes'), 'description': _('Show recently changed stock items on the homepage'), 'default': True, 'validator': bool, }, - 'HOMEPAGE_STOCK_LOW': { 'name': _('Show low stock'), 'description': _('Show low stock items on the homepage'), 'default': True, 'validator': bool, }, - 'HOMEPAGE_SHOW_STOCK_DEPLETED': { 'name': _('Show depleted stock'), 'description': _('Show depleted stock items on the homepage'), 'default': False, 'validator': bool, }, - 'HOMEPAGE_BUILD_STOCK_NEEDED': { 'name': _('Show needed stock'), 'description': _('Show stock items needed for builds on the homepage'), 'default': False, 'validator': bool, }, - 'HOMEPAGE_STOCK_EXPIRED': { 'name': _('Show expired stock'), 'description': _('Show expired stock items on the homepage'), 'default': True, 'validator': bool, }, - 'HOMEPAGE_STOCK_STALE': { 'name': _('Show stale stock'), 'description': _('Show stale stock items on the homepage'), 'default': True, 'validator': bool, }, - 'HOMEPAGE_BUILD_PENDING': { 'name': _('Show pending builds'), 'description': _('Show pending builds on the homepage'), 'default': True, 'validator': bool, }, - 'HOMEPAGE_BUILD_OVERDUE': { 'name': _('Show overdue builds'), 'description': _('Show overdue builds on the homepage'), 'default': True, 'validator': bool, }, - 'HOMEPAGE_PO_OUTSTANDING': { 'name': _('Show outstanding POs'), 'description': _('Show outstanding POs on the homepage'), 'default': True, 'validator': bool, }, - 'HOMEPAGE_PO_OVERDUE': { 'name': _('Show overdue POs'), 'description': _('Show overdue POs on the homepage'), 'default': True, 'validator': bool, }, - 'HOMEPAGE_SO_OUTSTANDING': { 'name': _('Show outstanding SOs'), 'description': _('Show outstanding SOs on the homepage'), 'default': True, 'validator': bool, }, - 'HOMEPAGE_SO_OVERDUE': { 'name': _('Show overdue SOs'), 'description': _('Show overdue SOs on the homepage'), 'default': True, 'validator': bool, }, - 'HOMEPAGE_SO_SHIPMENTS_PENDING': { 'name': _('Show pending SO shipments'), 'description': _('Show pending SO shipments on the homepage'), 'default': True, 'validator': bool, }, - 'HOMEPAGE_NEWS': { 'name': _('Show News'), 'description': _('Show news on the homepage'), 'default': False, 'validator': bool, }, - "LABEL_INLINE": { 'name': _('Inline label display'), - 'description': _('Display PDF labels in the browser, instead of downloading as a file'), + 'description': _( + 'Display PDF labels in the browser, instead of downloading as a file' + ), 'default': True, 'validator': bool, }, - "LABEL_DEFAULT_PRINTER": { 'name': _('Default label printer'), - 'description': _('Configure which label printer should be selected by default'), + 'description': _( + 'Configure which label printer should be selected by default' + ), 'default': '', - 'choices': label_printer_options + 'choices': label_printer_options, }, - "REPORT_INLINE": { 'name': _('Inline report display'), - 'description': _('Display PDF reports in the browser, instead of downloading as a file'), + 'description': _( + 'Display PDF reports in the browser, instead of downloading as a file' + ), 'default': False, 'validator': bool, }, - 'SEARCH_PREVIEW_SHOW_PARTS': { 'name': _('Search Parts'), 'description': _('Display parts in search preview window'), 'default': True, 'validator': bool, }, - 'SEARCH_PREVIEW_SHOW_SUPPLIER_PARTS': { 'name': _('Search Supplier Parts'), 'description': _('Display supplier parts in search preview window'), 'default': True, 'validator': bool, }, - 'SEARCH_PREVIEW_SHOW_MANUFACTURER_PARTS': { 'name': _('Search Manufacturer Parts'), 'description': _('Display manufacturer parts in search preview window'), 'default': True, 'validator': bool, }, - 'SEARCH_HIDE_INACTIVE_PARTS': { 'name': _("Hide Inactive Parts"), 'description': _('Excluded inactive parts from search preview window'), 'default': False, 'validator': bool, }, - 'SEARCH_PREVIEW_SHOW_CATEGORIES': { 'name': _('Search Categories'), 'description': _('Display part categories in search preview window'), 'default': False, 'validator': bool, }, - 'SEARCH_PREVIEW_SHOW_STOCK': { 'name': _('Search Stock'), 'description': _('Display stock items in search preview window'), 'default': True, 'validator': bool, }, - 'SEARCH_PREVIEW_HIDE_UNAVAILABLE_STOCK': { 'name': _('Hide Unavailable Stock Items'), - 'description': _('Exclude stock items which are not available from the search preview window'), + 'description': _( + 'Exclude stock items which are not available from the search preview window' + ), 'validator': bool, 'default': False, }, - 'SEARCH_PREVIEW_SHOW_LOCATIONS': { 'name': _('Search Locations'), 'description': _('Display stock locations in search preview window'), 'default': False, 'validator': bool, }, - 'SEARCH_PREVIEW_SHOW_COMPANIES': { 'name': _('Search Companies'), 'description': _('Display companies in search preview window'), 'default': True, 'validator': bool, }, - 'SEARCH_PREVIEW_SHOW_BUILD_ORDERS': { 'name': _('Search Build Orders'), 'description': _('Display build orders in search preview window'), 'default': True, 'validator': bool, }, - 'SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS': { 'name': _('Search Purchase Orders'), 'description': _('Display purchase orders in search preview window'), 'default': True, 'validator': bool, }, - 'SEARCH_PREVIEW_EXCLUDE_INACTIVE_PURCHASE_ORDERS': { 'name': _('Exclude Inactive Purchase Orders'), - 'description': _('Exclude inactive purchase orders from search preview window'), + 'description': _( + 'Exclude inactive purchase orders from search preview window' + ), 'default': True, 'validator': bool, }, - 'SEARCH_PREVIEW_SHOW_SALES_ORDERS': { 'name': _('Search Sales Orders'), 'description': _('Display sales orders in search preview window'), 'default': True, 'validator': bool, }, - 'SEARCH_PREVIEW_EXCLUDE_INACTIVE_SALES_ORDERS': { 'name': _('Exclude Inactive Sales Orders'), - 'description': _('Exclude inactive sales orders from search preview window'), + 'description': _( + 'Exclude inactive sales orders from search preview window' + ), 'validator': bool, 'default': True, }, - 'SEARCH_PREVIEW_SHOW_RETURN_ORDERS': { 'name': _('Search Return Orders'), 'description': _('Display return orders in search preview window'), 'default': True, 'validator': bool, }, - 'SEARCH_PREVIEW_EXCLUDE_INACTIVE_RETURN_ORDERS': { 'name': _('Exclude Inactive Return Orders'), - 'description': _('Exclude inactive return orders from search preview window'), + 'description': _( + 'Exclude inactive return orders from search preview window' + ), 'validator': bool, 'default': True, }, - 'SEARCH_PREVIEW_RESULTS': { 'name': _('Search Preview Results'), - 'description': _('Number of results to show in each section of the search preview window'), + 'description': _( + 'Number of results to show in each section of the search preview window' + ), 'default': 10, - 'validator': [int, MinValueValidator(1)] + 'validator': [int, MinValueValidator(1)], }, - 'SEARCH_REGEX': { 'name': _('Regex Search'), 'description': _('Enable regular expressions in search queries'), 'default': False, 'validator': bool, }, - 'SEARCH_WHOLE': { 'name': _('Whole Word Search'), 'description': _('Search queries return results for whole word matches'), 'default': False, 'validator': bool, }, - 'PART_SHOW_QUANTITY_IN_FORMS': { 'name': _('Show Quantity in Forms'), 'description': _('Display available part quantity in some forms'), 'default': True, 'validator': bool, }, - 'FORMS_CLOSE_USING_ESCAPE': { 'name': _('Escape Key Closes Forms'), 'description': _('Use the escape key to close modal forms'), 'default': False, 'validator': bool, }, - 'STICKY_HEADER': { 'name': _('Fixed Navbar'), 'description': _('The navbar position is fixed to the top of the screen'), 'default': False, 'validator': bool, }, - 'DATE_DISPLAY_FORMAT': { 'name': _('Date Format'), 'description': _('Preferred format for displaying dates'), @@ -2264,67 +2247,58 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): ('MM-DD-YYYY', '02-22-2022'), ('MM/DD/YYYY', '02/22/2022'), ('MMM DD YYYY', 'Feb 22 2022'), - ] + ], }, - 'DISPLAY_SCHEDULE_TAB': { 'name': _('Part Scheduling'), 'description': _('Display part scheduling information'), 'default': True, 'validator': bool, }, - 'DISPLAY_STOCKTAKE_TAB': { 'name': _('Part Stocktake'), - 'description': _('Display part stocktake information (if stocktake functionality is enabled)'), + 'description': _( + 'Display part stocktake information (if stocktake functionality is enabled)' + ), 'default': True, 'validator': bool, }, - 'TABLE_STRING_MAX_LENGTH': { 'name': _('Table String Length'), - 'description': _('Maximum length limit for strings displayed in table views'), - 'validator': [ - int, - MinValueValidator(0), - ], + 'description': _( + 'Maximum length limit for strings displayed in table views' + ), + 'validator': [int, MinValueValidator(0)], 'default': 100, }, - 'DEFAULT_PART_LABEL_TEMPLATE': { 'name': _('Default part label template'), 'description': _('The part label template to be automatically selected'), - 'validator': [ - int, - ], + 'validator': [int], 'default': '', }, - 'DEFAULT_ITEM_LABEL_TEMPLATE': { 'name': _('Default stock item template'), - 'description': _('The stock item label template to be automatically selected'), - 'validator': [ - int, - ], + 'description': _( + 'The stock item label template to be automatically selected' + ), + 'validator': [int], 'default': '', }, - 'DEFAULT_LOCATION_LABEL_TEMPLATE': { 'name': _('Default stock location label template'), - 'description': _('The stock location label template to be automatically selected'), - 'validator': [ - int, - ], + 'description': _( + 'The stock location label template to be automatically selected' + ), + 'validator': [int], 'default': '', }, - 'NOTIFICATION_ERROR_REPORT': { 'name': _('Receive error reports'), 'description': _('Receive notifications for system errors'), 'default': True, 'validator': bool, - } - + }, } typ = 'user' @@ -2340,7 +2314,8 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): user = models.ForeignKey( User, on_delete=models.CASCADE, - blank=True, null=True, + blank=True, + null=True, verbose_name=_('User'), help_text=_('User'), ) @@ -2384,13 +2359,24 @@ class PriceBreak(MetaMixin): try: converted = convert_money(self.price, currency_code) except MissingRate: - logger.warning("No currency conversion rate available for %s -> %s", self.price_currency, currency_code) + logger.warning( + "No currency conversion rate available for %s -> %s", + self.price_currency, + currency_code, + ) return self.price.amount return converted.amount -def get_price(instance, quantity, moq=True, multiples=True, currency=None, break_name: str = 'price_breaks'): +def get_price( + instance, + quantity, + moq=True, + multiples=True, + currency=None, + break_name: str = 'price_breaks', +): """Calculate the price based on quantity price breaks. - Don't forget to add in flat-fee cost (base_cost field) @@ -2409,7 +2395,7 @@ def get_price(instance, quantity, moq=True, multiples=True, currency=None, break return None # Check if quantity is fraction and disable multiples - multiples = (quantity % 1 == 0) + multiples = quantity % 1 == 0 # Order multiples if multiples: @@ -2461,12 +2447,10 @@ def get_price(instance, quantity, moq=True, multiples=True, currency=None, break class ColorTheme(models.Model): """Color Theme Setting.""" - name = models.CharField(max_length=20, - default='', - blank=True) - user = models.CharField(max_length=150, - unique=True) + name = models.CharField(max_length=20, default='', blank=True) + + user = models.CharField(max_length=150, unique=True) @classmethod def get_color_themes_choices(cls): @@ -2482,9 +2466,11 @@ class ColorTheme(models.Model): files_list.append([file.stem, file.suffix]) # Get color themes choices (CSS sheets) - choices = [(file_name.lower(), _(file_name.replace('-', ' ').title())) - for file_name, file_ext in files_list - if file_ext == '.css'] + choices = [ + (file_name.lower(), _(file_name.replace('-', ' ').title())) + for file_name, file_ext in files_list + if file_ext == '.css' + ] return choices @@ -2505,6 +2491,7 @@ class ColorTheme(models.Model): class VerificationMethod(Enum): """Class to hold method references.""" + NONE = 0 TOKEN = 1 HMAC = 2 @@ -2539,28 +2526,29 @@ class WebhookEndpoint(models.Model): name = models.CharField( max_length=255, - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Name'), - help_text=_('Name for this webhook') + help_text=_('Name for this webhook'), ) active = models.BooleanField( - default=True, - verbose_name=_('Active'), - help_text=_('Is this webhook active') + default=True, verbose_name=_('Active'), help_text=_('Is this webhook active') ) user = models.ForeignKey( User, on_delete=models.SET_NULL, - blank=True, null=True, + blank=True, + null=True, verbose_name=_('User'), help_text=_('User'), ) token = models.CharField( max_length=255, - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Token'), help_text=_('Token for access'), default=uuid.uuid4, @@ -2568,7 +2556,8 @@ class WebhookEndpoint(models.Model): secret = models.CharField( max_length=255, - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Secret'), help_text=_('Shared secret for HMAC'), ) @@ -2614,7 +2603,9 @@ class WebhookEndpoint(models.Model): # hmac token elif self.verify == VerificationMethod.HMAC: - digest = hmac.new(self.secret.encode('utf-8'), request.body, hashlib.sha256).digest() + digest = hmac.new( + self.secret.encode('utf-8'), request.body, hashlib.sha256 + ).digest() computed_hmac = base64.b64encode(digest) if not hmac.compare_digest(computed_hmac, token.encode('utf-8')): raise PermissionDenied(self.MESSAGE_TOKEN_ERROR) @@ -2692,14 +2683,16 @@ class WebhookMessage(models.Model): header = models.CharField( max_length=255, - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Header'), help_text=_('Header of this message'), editable=False, ) body = models.JSONField( - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Body'), help_text=_('Body of this message'), editable=False, @@ -2708,7 +2701,8 @@ class WebhookMessage(models.Model): endpoint = models.ForeignKey( WebhookEndpoint, on_delete=models.SET_NULL, - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Endpoint'), help_text=_('Endpoint on which this message was received'), ) @@ -2734,38 +2728,25 @@ class NotificationEntry(MetaMixin): class Meta: """Meta options for NotificationEntry.""" - unique_together = [ - ('key', 'uid'), - ] + unique_together = [('key', 'uid')] - key = models.CharField( - max_length=250, - blank=False, - ) + key = models.CharField(max_length=250, blank=False) - uid = models.IntegerField( - ) + uid = models.IntegerField() @classmethod def check_recent(cls, key: str, uid: int, delta: timedelta): """Test if a particular notification has been sent in the specified time period.""" since = datetime.now().date() - delta - entries = cls.objects.filter( - key=key, - uid=uid, - updated__gte=since - ) + entries = cls.objects.filter(key=key, uid=uid, updated__gte=since) return entries.exists() @classmethod def notify(cls, key: str, uid: int): """Notify the database that a particular notification has been sent out.""" - entry, created = cls.objects.get_or_create( - key=key, - uid=uid - ) + entry, created = cls.objects.get_or_create(key=key, uid=uid) entry.save() @@ -2782,9 +2763,7 @@ class NotificationMessage(models.Model): # generic link to target target_content_type = models.ForeignKey( - ContentType, - on_delete=models.CASCADE, - related_name='notification_target', + ContentType, on_delete=models.CASCADE, related_name='notification_target' ) target_object_id = models.PositiveIntegerField() @@ -2800,10 +2779,7 @@ class NotificationMessage(models.Model): blank=True, ) - source_object_id = models.PositiveIntegerField( - null=True, - blank=True, - ) + source_object_id = models.PositiveIntegerField(null=True, blank=True) source_object = GenericForeignKey('source_content_type', 'source_object_id') @@ -2817,29 +2793,15 @@ class NotificationMessage(models.Model): blank=True, ) - category = models.CharField( - max_length=250, - blank=False, - ) + category = models.CharField(max_length=250, blank=False) - name = models.CharField( - max_length=250, - blank=False, - ) + name = models.CharField(max_length=250, blank=False) - message = models.CharField( - max_length=250, - blank=True, - null=True, - ) + message = models.CharField(max_length=250, blank=True, null=True) - creation = models.DateTimeField( - auto_now_add=True, - ) + creation = models.DateTimeField(auto_now_add=True) - read = models.BooleanField( - default=False, - ) + read = models.BooleanField(default=False) @staticmethod def get_api_url(): @@ -2869,41 +2831,20 @@ class NewsFeedEntry(models.Model): - read: Was this iteam already by a superuser? """ - feed_id = models.CharField( - verbose_name=_('Id'), - unique=True, - max_length=250, - ) + feed_id = models.CharField(verbose_name=_('Id'), unique=True, max_length=250) - title = models.CharField( - verbose_name=_('Title'), - max_length=250, - ) + title = models.CharField(verbose_name=_('Title'), max_length=250) - link = models.URLField( - verbose_name=_('Link'), - max_length=250, - ) + link = models.URLField(verbose_name=_('Link'), max_length=250) - published = models.DateTimeField( - verbose_name=_('Published'), - max_length=250, - ) + published = models.DateTimeField(verbose_name=_('Published'), max_length=250) - author = models.CharField( - verbose_name=_('Author'), - max_length=250, - ) + author = models.CharField(verbose_name=_('Author'), max_length=250) - summary = models.CharField( - verbose_name=_('Summary'), - max_length=250, - ) + summary = models.CharField(verbose_name=_('Summary'), max_length=250) read = models.BooleanField( - verbose_name=_('Read'), - help_text=_('Was this news item read?'), - default=False + verbose_name=_('Read'), help_text=_('Was this news item read?'), default=False ) @@ -2920,9 +2861,7 @@ class NotesImage(models.Model): """ image = models.ImageField( - upload_to=rename_notes_image, - verbose_name=_('Image'), - help_text=_('Image file'), + upload_to=rename_notes_image, verbose_name=_('Image'), help_text=_('Image file') ) user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) @@ -2964,9 +2903,7 @@ class CustomUnit(models.Model): # Cannot be zero length if not self.name.isidentifier(): - raise ValidationError({ - 'name': _('Unit name must be a valid identifier') - }) + raise ValidationError({'name': _('Unit name must be a valid identifier')}) self.definition = self.definition.strip() @@ -2974,9 +2911,7 @@ class CustomUnit(models.Model): try: registry.Quantity(self.definition) except Exception as exc: - raise ValidationError({ - 'definition': str(exc) - }) + raise ValidationError({'definition': str(exc)}) # Finally, test that the entire custom unit definition is valid try: @@ -2988,14 +2923,16 @@ class CustomUnit(models.Model): max_length=50, verbose_name=_('Name'), help_text=_('Unit name'), - unique=True, blank=False, + unique=True, + blank=False, ) symbol = models.CharField( max_length=10, verbose_name=_('Symbol'), help_text=_('Optional unit symbol'), - unique=True, blank=True, + unique=True, + blank=True, ) definition = models.CharField( @@ -3012,4 +2949,5 @@ def after_custom_unit_updated(sender, instance, **kwargs): """Callback when a custom unit is updated or deleted""" # Force reload of the unit registry from InvenTree.conversion import reload_unit_registry + reload_unit_registry() diff --git a/InvenTree/common/notifications.py b/InvenTree/common/notifications.py index c32ee3ec82..3dfe2fb716 100644 --- a/InvenTree/common/notifications.py +++ b/InvenTree/common/notifications.py @@ -24,7 +24,7 @@ class NotificationMethod: METHOD_NAME = '' METHOD_ICON = None - CONTEXT_BUILTIN = ['name', 'message', ] + CONTEXT_BUILTIN = ['name', 'message'] CONTEXT_EXTRA = [] GLOBAL_SETTING = None USER_SETTING = None @@ -39,11 +39,15 @@ class NotificationMethod: """ # Check if a sending fnc is defined if (not hasattr(self, 'send')) and (not hasattr(self, 'send_bulk')): - raise NotImplementedError('A NotificationMethod must either define a `send` or a `send_bulk` method') + raise NotImplementedError( + 'A NotificationMethod must either define a `send` or a `send_bulk` method' + ) # No method name is no good if self.METHOD_NAME in ('', None): - raise NotImplementedError(f'The NotificationMethod {self.__class__} did not provide a METHOD_NAME') + raise NotImplementedError( + f'The NotificationMethod {self.__class__} did not provide a METHOD_NAME' + ) # Check if plugin is disabled - if so do not gather targets etc. if self.global_setting_disable(): @@ -61,9 +65,10 @@ class NotificationMethod: def check_context(self, context): """Check that all values defined in the methods CONTEXT were provided in the current context.""" + def check(ref, obj): # the obj is not accessible so we are on the end - if not isinstance(obj, (list, dict, tuple, )): + if not isinstance(obj, (list, dict, tuple)): return ref # check if the ref exists @@ -82,7 +87,9 @@ class NotificationMethod: return check(ref[1:], obj[ref[0]]) # other cases -> raise - raise NotImplementedError('This type can not be used as a context reference') + raise NotImplementedError( + 'This type can not be used as a context reference' + ) missing = [] for item in (*self.CONTEXT_BUILTIN, *self.CONTEXT_EXTRA): @@ -91,7 +98,9 @@ class NotificationMethod: missing.append(ret) if missing: - raise NotImplementedError(f'The `context` is missing the following items:\n{missing}') + raise NotImplementedError( + f'The `context` is missing the following items:\n{missing}' + ) return context @@ -142,7 +151,12 @@ class NotificationMethod: def usersetting(self, target): """Returns setting for this method for a given user.""" - return NotificationUserSetting.get_setting(f'NOTIFICATION_METHOD_{self.METHOD_NAME.upper()}', user=target, method=self.METHOD_NAME) + return NotificationUserSetting.get_setting( + f'NOTIFICATION_METHOD_{self.METHOD_NAME.upper()}', + user=target, + method=self.METHOD_NAME, + ) + # endregion @@ -160,6 +174,8 @@ class BulkNotificationMethod(NotificationMethod): def send_bulk(self): """This function must be overridden.""" raise NotImplementedError('The `send` method must be overridden!') + + # endregion @@ -181,17 +197,25 @@ class MethodStorageClass: selected_classes (class, optional): References to the classes that should be registered. Defaults to None. """ logger.debug('Collecting notification methods') - current_method = InvenTree.helpers.inheritors(NotificationMethod) - IGNORED_NOTIFICATION_CLS + current_method = ( + InvenTree.helpers.inheritors(NotificationMethod) - IGNORED_NOTIFICATION_CLS + ) # for testing selective loading is made available if selected_classes: - current_method = [item for item in current_method if item is selected_classes] + current_method = [ + item for item in current_method if item is selected_classes + ] # make sure only one of each method is added filtered_list = {} for item in current_method: plugin = item.get_plugin(item) - ref = f'{plugin.package_path}_{item.METHOD_NAME}' if plugin else item.METHOD_NAME + ref = ( + f'{plugin.package_path}_{item.METHOD_NAME}' + if plugin + else item.METHOD_NAME + ) item.plugin = plugin() if plugin else None filtered_list[ref] = item @@ -217,9 +241,7 @@ class MethodStorageClass: # make sure the setting exists self.user_settings[new_key] = item.USER_SETTING NotificationUserSetting.get_setting( - key=new_key, - user=user, - method=item.METHOD_NAME, + key=new_key, user=user, method=item.METHOD_NAME ) # save definition @@ -231,7 +253,7 @@ class MethodStorageClass: return methods -IGNORED_NOTIFICATION_CLS = {SingleNotificationMethod, BulkNotificationMethod, } +IGNORED_NOTIFICATION_CLS = {SingleNotificationMethod, BulkNotificationMethod} storage = MethodStorageClass() @@ -275,6 +297,7 @@ class NotificationBody: app_label: App label (slugified) of the model model_name': Name (slugified) of the model """ + name: str slug: str message: str @@ -286,6 +309,7 @@ class InvenTreeNotificationBodies: Contains regularly used notification bodies. """ + NewOrder = NotificationBody( name=_("New {verbose_name}"), slug='{app_label}.new_{model_name}', @@ -340,13 +364,19 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs): if not obj_ref_value: obj_ref_value = getattr(obj, 'id', None) if not obj_ref_value: - raise KeyError(f"Could not resolve an object reference for '{str(obj)}' with {obj_ref}, pk, id") + raise KeyError( + f"Could not resolve an object reference for '{str(obj)}' with {obj_ref}, pk, id" + ) # Check if we have notified recently... delta = timedelta(days=1) if common.models.NotificationEntry.check_recent(category, obj_ref_value, delta): - logger.info("Notification '%s' has recently been sent for '%s' - SKIPPING", category, str(obj)) + logger.info( + "Notification '%s' has recently been sent for '%s' - SKIPPING", + category, + str(obj), + ) return logger.info("Gathering users for notification '%s'", category) @@ -383,7 +413,9 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs): target_users.add(user) # Unhandled type else: - logger.error("Unknown target passed to trigger_notification method: %s", target) + logger.error( + "Unknown target passed to trigger_notification method: %s", target + ) if target_users: logger.info("Sending notification '%s' for '%s'", category, str(obj)) @@ -392,7 +424,7 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs): if delivery_methods is None: delivery_methods = storage.liste else: - delivery_methods = (delivery_methods - IGNORED_NOTIFICATION_CLS) + delivery_methods = delivery_methods - IGNORED_NOTIFICATION_CLS for method in delivery_methods: logger.info("Triggering notification method '%s'", method.METHOD_NAME) @@ -422,17 +454,15 @@ def trigger_superuser_notification(plugin: PluginConfig, msg: str): trigger_notification( plugin, 'inventree.plugin', - context={ - 'error': plugin, - 'name': _('Error raised by plugin'), - 'message': msg, - }, + context={'error': plugin, 'name': _('Error raised by plugin'), 'message': msg}, targets=users, - delivery_methods={UIMessageNotification, }, + delivery_methods={UIMessageNotification}, ) -def deliver_notification(cls: NotificationMethod, obj, category: str, targets, context: dict): +def deliver_notification( + cls: NotificationMethod, obj, category: str, targets, context: dict +): """Send notification with the provided class. This: @@ -447,7 +477,12 @@ def deliver_notification(cls: NotificationMethod, obj, category: str, targets, c if method.targets and len(method.targets) > 0: # Log start - logger.info("Notify users via '%s' for notification '%s' for '%s'", method.METHOD_NAME, category, str(obj)) + logger.info( + "Notify users via '%s' for notification '%s' for '%s'", + method.METHOD_NAME, + category, + str(obj), + ) # Run setup for delivery method method.setup() @@ -472,6 +507,12 @@ def deliver_notification(cls: NotificationMethod, obj, category: str, targets, c method.cleanup() # Log results - logger.info("Notified %s users via '%s' for notification '%s' for '%s' successfully", success_count, method.METHOD_NAME, category, str(obj)) + logger.info( + "Notified %s users via '%s' for notification '%s' for '%s' successfully", + success_count, + method.METHOD_NAME, + category, + str(obj), + ) if not success: logger.info("There were some problems") diff --git a/InvenTree/common/serializers.py b/InvenTree/common/serializers.py index 06a6d226e9..587595ecc8 100644 --- a/InvenTree/common/serializers.py +++ b/InvenTree/common/serializers.py @@ -1,6 +1,5 @@ """JSON serializers for common components.""" - from django.urls import reverse from flags.state import flag_state @@ -9,8 +8,10 @@ from rest_framework import serializers import common.models as common_models from InvenTree.helpers import get_objectreference from InvenTree.helpers_model import construct_absolute_url -from InvenTree.serializers import (InvenTreeImageSerializerField, - InvenTreeModelSerializer) +from InvenTree.serializers import ( + InvenTreeImageSerializerField, + InvenTreeModelSerializer, +) from users.serializers import OwnerSerializer @@ -62,10 +63,7 @@ class SettingsSerializer(InvenTreeModelSerializer): if choices: for choice in choices: - results.append({ - 'value': choice[0], - 'display_name': choice[1], - }) + results.append({'value': choice[0], 'display_name': choice[1]}) return results @@ -131,8 +129,10 @@ class GenericReferencedSettingSerializer(SettingsSerializer): def __init__(self, *args, **kwargs): """Init overrides the Meta class to make it dynamic.""" + class CustomMeta: """Scaffold for custom Meta class.""" + fields = [ 'pk', 'key', @@ -204,10 +204,12 @@ class NotificationMessageSerializer(InvenTreeModelSerializer): request = self.context['request'] if request.user and request.user.is_staff: meta = obj.target_object._meta - target['link'] = construct_absolute_url(reverse( - f'admin:{meta.db_table}_change', - kwargs={'object_id': obj.target_object_id} - )) + target['link'] = construct_absolute_url( + reverse( + f'admin:{meta.db_table}_change', + kwargs={'object_id': obj.target_object_id}, + ) + ) return target @@ -257,17 +259,9 @@ class NotesImageSerializer(InvenTreeModelSerializer): """Meta options for NotesImageSerializer.""" model = common_models.NotesImage - fields = [ - 'pk', - 'image', - 'user', - 'date', - ] + fields = ['pk', 'image', 'user', 'date'] - read_only_fields = [ - 'date', - 'user', - ] + read_only_fields = ['date', 'user'] image = InvenTreeImageSerializerField(required=True) @@ -279,13 +273,7 @@ class ProjectCodeSerializer(InvenTreeModelSerializer): """Meta options for ProjectCodeSerializer.""" model = common_models.ProjectCode - fields = [ - 'pk', - 'code', - 'description', - 'responsible', - 'responsible_detail', - ] + fields = ['pk', 'code', 'description', 'responsible', 'responsible_detail'] responsible_detail = OwnerSerializer(source='responsible', read_only=True) @@ -313,9 +301,4 @@ class CustomUnitSerializer(InvenTreeModelSerializer): """Meta options for CustomUnitSerializer.""" model = common_models.CustomUnit - fields = [ - 'pk', - 'name', - 'symbol', - 'definition', - ] + fields = ['pk', 'name', 'symbol', 'definition'] diff --git a/InvenTree/common/settings.py b/InvenTree/common/settings.py index b62f081a28..eca01311bd 100644 --- a/InvenTree/common/settings.py +++ b/InvenTree/common/settings.py @@ -20,7 +20,9 @@ def currency_code_default(): return cached_value try: - code = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY', backup_value='', create=True, cache=True) + code = InvenTreeSetting.get_setting( + 'INVENTREE_DEFAULT_CURRENCY', backup_value='', create=True, cache=True + ) except Exception: # pragma: no cover # Database may not yet be ready, no need to throw an error here code = '' diff --git a/InvenTree/common/tasks.py b/InvenTree/common/tasks.py index 1725dce0ce..fca5a49f8c 100644 --- a/InvenTree/common/tasks.py +++ b/InvenTree/common/tasks.py @@ -27,7 +27,9 @@ def delete_old_notifications(): try: from common.models import NotificationEntry except AppRegistryNotReady: # pragma: no cover - logger.info("Could not perform 'delete_old_notifications' - App registry not ready") + logger.info( + "Could not perform 'delete_old_notifications' - App registry not ready" + ) return before = timezone.now() - timedelta(days=90) @@ -87,7 +89,9 @@ def delete_old_notes_images(): try: from common.models import NotesImage except AppRegistryNotReady: - logger.info("Could not perform 'delete_old_notes_images' - App registry not ready") + logger.info( + "Could not perform 'delete_old_notes_images' - App registry not ready" + ) return # Remove any notes which point to non-existent image files @@ -127,7 +131,6 @@ def delete_old_notes_images(): all_notes = NotesImage.objects.all() for image in images: - found = False for note in all_notes: img_path = os.path.basename(note.image.path) diff --git a/InvenTree/common/test_notifications.py b/InvenTree/common/test_notifications.py index e61e60dfc0..a517d51e33 100644 --- a/InvenTree/common/test_notifications.py +++ b/InvenTree/common/test_notifications.py @@ -1,8 +1,12 @@ """Tests for basic notification methods and functions in InvenTree.""" import plugin.templatetags.plugin_extras as plugin_tags -from common.notifications import (BulkNotificationMethod, NotificationMethod, - SingleNotificationMethod, storage) +from common.notifications import ( + BulkNotificationMethod, + NotificationMethod, + SingleNotificationMethod, + storage, +) from part.test_part import BaseNotificationIntegrationTest from plugin.models import NotificationUserSetting @@ -23,37 +27,31 @@ class BaseNotificationTests(BaseNotificationIntegrationTest): """A comment so we do not need a pass.""" class NoNameNotificationMethod(NotificationMethod): - def send(self): """A comment so we do not need a pass.""" class WrongContextNotificationMethod(NotificationMethod): METHOD_NAME = 'WrongContextNotification' - CONTEXT_EXTRA = [ - 'aa', - ('aa', 'bb', ), - ('templates', 'ccc', ), - (123, ) - ] + CONTEXT_EXTRA = ['aa', ('aa', 'bb'), ('templates', 'ccc'), (123,)] def send(self): """A comment so we do not need a pass.""" # no send / send bulk with self.assertRaises(NotImplementedError): - FalseNotificationMethod('', '', '', '', ) + FalseNotificationMethod('', '', '', '') # no METHOD_NAME with self.assertRaises(NotImplementedError): - NoNameNotificationMethod('', '', '', '', ) + NoNameNotificationMethod('', '', '', '') # a not existent context check with self.assertRaises(NotImplementedError): - WrongContextNotificationMethod('', '', '', '', ) + WrongContextNotificationMethod('', '', '', '') # no get_targets with self.assertRaises(NotImplementedError): - AnotherFalseNotificationMethod('', '', '', {'name': 1, 'message': 2, }, ) + AnotherFalseNotificationMethod('', '', '', {'name': 1, 'message': 2}) def test_failing_passing(self): """Ensure that an error in one deliverymethod is not blocking all mehthods.""" @@ -67,7 +65,7 @@ class BaseNotificationTests(BaseNotificationIntegrationTest): METHOD_NAME = 'ErrorImplementation' def get_targets(self): - return [1, ] + return [1] def send(self, target): raise KeyError('This could be any error') @@ -91,7 +89,7 @@ class BulkNotificationMethodTests(BaseNotificationIntegrationTest): METHOD_NAME = 'WrongImplementationBulk' def get_targets(self): - return [1, ] + return [1] with self.assertLogs(logger='inventree', level='ERROR'): self._notification_run(WrongImplementation) @@ -113,11 +111,12 @@ class SingleNotificationMethodTests(BaseNotificationIntegrationTest): METHOD_NAME = 'WrongImplementationSingle' def get_targets(self): - return [1, ] + return [1] with self.assertLogs(logger='inventree', level='ERROR'): self._notification_run(WrongImplementation) + # A integration test for notifications is provided in test_part.PartNotificationTest @@ -144,7 +143,7 @@ class NotificationUserSettingTests(BaseNotificationIntegrationTest): } def get_targets(self): - return [1, ] + return [1] def send_bulk(self): return True @@ -158,10 +157,14 @@ class NotificationUserSettingTests(BaseNotificationIntegrationTest): # assertions for settings self.assertEqual(setting.name, 'Enable test notifications') self.assertEqual(setting.default_value, True) - self.assertEqual(setting.description, 'Allow sending of test for event notifications') + self.assertEqual( + setting.description, 'Allow sending of test for event notifications' + ) self.assertEqual(setting.units, 'alpha') # test tag and array - self.assertEqual(plugin_tags.notification_settings_list({'user': self.user}), array) + self.assertEqual( + plugin_tags.notification_settings_list({'user': self.user}), array + ) self.assertEqual(array[0]['key'], 'NOTIFICATION_METHOD_TEST') self.assertEqual(array[0]['method'], 'test') diff --git a/InvenTree/common/test_tasks.py b/InvenTree/common/test_tasks.py index d551bc82ac..55821dbbf2 100644 --- a/InvenTree/common/test_tasks.py +++ b/InvenTree/common/test_tasks.py @@ -15,4 +15,4 @@ class TaskTest(TestCase): """Test that the task `delete_old_notifications` runs through without errors.""" # check empty run self.assertEqual(NotificationEntry.objects.all().count(), 0) - offload_task(common_tasks.delete_old_notifications,) + offload_task(common_tasks.delete_old_notifications) diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py index 61590411f8..dfd764c7bc 100644 --- a/InvenTree/common/tests.py +++ b/InvenTree/common/tests.py @@ -17,16 +17,23 @@ from django.urls import reverse import PIL from InvenTree.helpers import str2bool -from InvenTree.unit_test import (InvenTreeAPITestCase, InvenTreeTestCase, - PluginMixin) +from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase, PluginMixin from plugin import registry from plugin.models import NotificationUserSetting from .api import WebhookView -from .models import (ColorTheme, CustomUnit, InvenTreeSetting, - InvenTreeUserSetting, NotesImage, NotificationEntry, - NotificationMessage, ProjectCode, WebhookEndpoint, - WebhookMessage) +from .models import ( + ColorTheme, + CustomUnit, + InvenTreeSetting, + InvenTreeUserSetting, + NotesImage, + NotificationEntry, + NotificationMessage, + ProjectCode, + WebhookEndpoint, + WebhookMessage, +) CONTENT_TYPE_JSON = 'application/json' @@ -34,9 +41,7 @@ CONTENT_TYPE_JSON = 'application/json' class SettingsTest(InvenTreeTestCase): """Tests for the 'settings' model.""" - fixtures = [ - 'settings', - ] + fixtures = ['settings'] def test_settings_objects(self): """Test fixture loading and lookup for settings.""" @@ -50,7 +55,9 @@ class SettingsTest(InvenTreeTestCase): self.assertEqual(instance_name.value, 'My very first InvenTree Instance') # Check object lookup (case insensitive) - self.assertEqual(InvenTreeSetting.get_setting_object('iNvEnTrEE_inSTanCE').pk, 1) + self.assertEqual( + InvenTreeSetting.get_setting_object('iNvEnTrEE_inSTanCE').pk, 1 + ) def test_settings_functions(self): """Test settings functions and properties.""" @@ -61,14 +68,25 @@ class SettingsTest(InvenTreeTestCase): stale_ref = 'STOCK_STALE_DAYS' stale_days = InvenTreeSetting.get_setting_object(stale_ref, cache=False) - report_size_obj = InvenTreeSetting.get_setting_object('REPORT_DEFAULT_PAGE_SIZE') - report_test_obj = InvenTreeSetting.get_setting_object('REPORT_ENABLE_TEST_REPORT') + report_size_obj = InvenTreeSetting.get_setting_object( + 'REPORT_DEFAULT_PAGE_SIZE' + ) + report_test_obj = InvenTreeSetting.get_setting_object( + 'REPORT_ENABLE_TEST_REPORT' + ) # check settings base fields self.assertEqual(instance_obj.name, 'Server Instance Name') - self.assertEqual(instance_obj.get_setting_name(instance_ref), 'Server Instance Name') - self.assertEqual(instance_obj.description, 'String descriptor for the server instance') - self.assertEqual(instance_obj.get_setting_description(instance_ref), 'String descriptor for the server instance') + self.assertEqual( + instance_obj.get_setting_name(instance_ref), 'Server Instance Name' + ) + self.assertEqual( + instance_obj.description, 'String descriptor for the server instance' + ) + self.assertEqual( + instance_obj.get_setting_description(instance_ref), + 'String descriptor for the server instance', + ) # check units self.assertEqual(instance_obj.units, '') @@ -90,7 +108,9 @@ class SettingsTest(InvenTreeTestCase): # check as_int self.assertEqual(stale_days.as_int(), 0) - self.assertEqual(instance_obj.as_int(), 'InvenTree') # not an int -> return default + self.assertEqual( + instance_obj.as_int(), 'InvenTree' + ) # not an int -> return default # check as_bool self.assertEqual(report_test_obj.as_bool(), True) @@ -125,20 +145,23 @@ class SettingsTest(InvenTreeTestCase): # define partial schema settings_definition = { "AB": { # key that's has not already been accessed - "required": True, + "required": True }, - "CD": { - "required": True, - "protected": True, - }, - "EF": {} + "CD": {"required": True, "protected": True}, + "EF": {}, } def mocked(key, **kwargs): return settings_definition.get(key, {}) + get_setting_definition.side_effect = mocked - self.assertEqual(InvenTreeSetting.check_all_settings(settings_definition=settings_definition), (False, ["AB", "CD"])) + self.assertEqual( + InvenTreeSetting.check_all_settings( + settings_definition=settings_definition + ), + (False, ["AB", "CD"]), + ) InvenTreeSetting.set_setting('AB', "hello", self.user) InvenTreeSetting.set_setting('CD', "world", self.user) self.assertEqual(InvenTreeSetting.check_all_settings(), (True, [])) @@ -158,12 +181,13 @@ class SettingsTest(InvenTreeTestCase): # define partial schema settings_definition = { "AB": { # key that's has not already been accessed - "validator": mock_validator, - }, + "validator": mock_validator + } } def mocked(key, **kwargs): return settings_definition.get(key, {}) + get_setting_definition.side_effect = mocked InvenTreeSetting.set_setting("AB", "hello", self.user) @@ -194,7 +218,9 @@ class SettingsTest(InvenTreeTestCase): self.assertIn('django.utils.functional.lazy', str(type(description))) if key != key.upper(): - raise ValueError(f"Setting key '{key}' is not uppercase") # pragma: no cover + raise ValueError( + f"Setting key '{key}' is not uppercase" + ) # pragma: no cover # Check that only allowed keys are provided allowed_keys = [ @@ -232,7 +258,6 @@ class SettingsTest(InvenTreeTestCase): - Ensure that every setting has a description, which is translated """ for key, setting in InvenTreeSetting.SETTINGS.items(): - try: self.run_settings_check(key, setting) except Exception as exc: # pragma: no cover @@ -249,7 +274,6 @@ class SettingsTest(InvenTreeTestCase): def test_defaults(self): """Populate the settings with default values.""" for key in InvenTreeSetting.SETTINGS.keys(): - value = InvenTreeSetting.get_setting_default(key) InvenTreeSetting.set_setting(key, value, self.user) @@ -261,10 +285,14 @@ class SettingsTest(InvenTreeTestCase): if setting.is_bool(): if setting.default_value in ['', None]: - raise ValueError(f'Default value for boolean setting {key} not provided') # pragma: no cover + raise ValueError( + f'Default value for boolean setting {key} not provided' + ) # pragma: no cover if setting.default_value not in [True, False]: - raise ValueError(f'Non-boolean default value specified for {key}') # pragma: no cover + raise ValueError( + f'Non-boolean default value specified for {key}' + ) # pragma: no cover def test_global_setting_caching(self): """Test caching operations for the global settings class""" @@ -294,9 +322,7 @@ class SettingsTest(InvenTreeTestCase): # Generate a number of new users for idx in range(5): get_user_model().objects.create( - username=f"User_{idx}", - password="hunter42", - email="email@dot.com", + username=f"User_{idx}", password="hunter42", email="email@dot.com" ) key = 'SEARCH_PREVIEW_RESULTS' @@ -305,7 +331,10 @@ class SettingsTest(InvenTreeTestCase): for user in get_user_model().objects.all(): setting = InvenTreeUserSetting.get_setting_object(key, user=user) cache_key = setting.cache_key - self.assertEqual(cache_key, f"InvenTreeUserSetting:SEARCH_PREVIEW_RESULTS_user:{user.username}") + self.assertEqual( + cache_key, + f"InvenTreeUserSetting:SEARCH_PREVIEW_RESULTS_user:{user.username}", + ) InvenTreeUserSetting.set_setting(key, user.pk, None, user=user) self.assertIsNotNone(cache.get(cache_key)) @@ -333,7 +362,9 @@ class GlobalSettingsApiTest(InvenTreeAPITestCase): response = self.get(url, expected_code=200) - n_public_settings = len([k for k in InvenTreeSetting.SETTINGS.keys() if not k.startswith('_')]) + n_public_settings = len([ + k for k in InvenTreeSetting.SETTINGS.keys() if not k.startswith('_') + ]) # Number of results should match the number of settings self.assertEqual(len(response.data), n_public_settings) @@ -358,13 +389,7 @@ class GlobalSettingsApiTest(InvenTreeAPITestCase): # Test setting via the API for val in ['cat', 'hat', 'bat', 'mat']: - response = self.patch( - url, - { - 'value': val, - }, - expected_code=200 - ) + response = self.patch(url, {'value': val}, expected_code=200) self.assertEqual(response.data['value'], val) @@ -394,13 +419,7 @@ class GlobalSettingsApiTest(InvenTreeAPITestCase): self.assertEqual(response.data['value'], 'InvenTree') # Now, the object should have been created in the DB - self.patch( - url, - { - 'value': 'My new title', - }, - expected_code=200, - ) + self.patch(url, {'value': 'My new title'}, expected_code=200) setting = InvenTreeSetting.objects.get(key=key) @@ -451,8 +470,7 @@ class UserSettingsApiTest(InvenTreeAPITestCase): """Test a boolean user setting value.""" # Ensure we have a boolean setting available setting = InvenTreeUserSetting.get_setting_object( - 'SEARCH_PREVIEW_SHOW_PARTS', - user=self.user + 'SEARCH_PREVIEW_SHOW_PARTS', user=self.user ) # Check default values @@ -465,20 +483,16 @@ class UserSettingsApiTest(InvenTreeAPITestCase): self.assertEqual(response.data['pk'], setting.pk) self.assertEqual(response.data['key'], 'SEARCH_PREVIEW_SHOW_PARTS') - self.assertEqual(response.data['description'], 'Display parts in search preview window') + self.assertEqual( + response.data['description'], 'Display parts in search preview window' + ) self.assertEqual(response.data['type'], 'boolean') self.assertEqual(len(response.data['choices']), 0) self.assertTrue(str2bool(response.data['value'])) # Assign some truthy values for v in ['true', True, 1, 'y', 'TRUE']: - self.patch( - url, - { - 'value': str(v), - }, - expected_code=200, - ) + self.patch(url, {'value': str(v)}, expected_code=200) response = self.get(url, expected_code=200) @@ -486,13 +500,7 @@ class UserSettingsApiTest(InvenTreeAPITestCase): # Assign some false(ish) values for v in ['false', False, '0', 'n', 'FalSe']: - self.patch( - url, - { - 'value': str(v), - }, - expected_code=200, - ) + self.patch(url, {'value': str(v)}, expected_code=200) response = self.get(url, expected_code=200) @@ -500,13 +508,7 @@ class UserSettingsApiTest(InvenTreeAPITestCase): # Assign some invalid values for v in ['x', '', 'invalid', None, '-1', 'abcde']: - response = self.patch( - url, - { - 'value': str(v), - }, - expected_code=200 - ) + response = self.patch(url, {'value': str(v)}, expected_code=200) # Invalid values evaluate to False self.assertFalse(str2bool(response.data['value'])) @@ -514,8 +516,7 @@ class UserSettingsApiTest(InvenTreeAPITestCase): def test_user_setting_choice(self): """Test a user setting with choices.""" setting = InvenTreeUserSetting.get_setting_object( - 'DATE_DISPLAY_FORMAT', - user=self.user + 'DATE_DISPLAY_FORMAT', user=self.user ) url = reverse('api-user-setting-detail', kwargs={'key': setting.key}) @@ -525,37 +526,21 @@ class UserSettingsApiTest(InvenTreeAPITestCase): # Check that a valid option can be assigned via the API for opt in ['YYYY-MM-DD', 'DD-MM-YYYY', 'MM/DD/YYYY']: - - self.patch( - url, - { - 'value': opt, - }, - expected_code=200, - ) + self.patch(url, {'value': opt}, expected_code=200) setting.refresh_from_db() self.assertEqual(setting.value, opt) # Send an invalid option for opt in ['cat', 'dog', 12345]: - - response = self.patch( - url, - { - 'value': opt, - }, - expected_code=400, - ) + response = self.patch(url, {'value': opt}, expected_code=400) self.assertIn('Chosen value is not a valid option', str(response.data)) def test_user_setting_integer(self): """Test a integer user setting value.""" setting = InvenTreeUserSetting.get_setting_object( - 'SEARCH_PREVIEW_RESULTS', - user=self.user, - cache=False, + 'SEARCH_PREVIEW_RESULTS', user=self.user, cache=False ) url = reverse('api-user-setting-detail', kwargs={'key': setting.key}) @@ -573,13 +558,7 @@ class UserSettingsApiTest(InvenTreeAPITestCase): # Set valid options via the api for v in [5, 15, 25]: - self.patch( - url, - { - 'value': v, - }, - expected_code=200, - ) + self.patch(url, {'value': v}, expected_code=200) setting.refresh_from_db() self.assertEqual(setting.to_native_value(), v) @@ -587,14 +566,7 @@ class UserSettingsApiTest(InvenTreeAPITestCase): # Set invalid options via the API # Note that this particular setting has a MinValueValidator(1) associated with it for v in [0, -1, -5]: - - response = self.patch( - url, - { - 'value': v, - }, - expected_code=400, - ) + response = self.patch(url, {'value': v}, expected_code=400) class NotificationUserSettingsApiTest(InvenTreeAPITestCase): @@ -608,9 +580,15 @@ class NotificationUserSettingsApiTest(InvenTreeAPITestCase): def test_setting(self): """Test the string name for NotificationUserSetting.""" - NotificationUserSetting.set_setting('NOTIFICATION_METHOD_MAIL', True, change_user=self.user, user=self.user) - test_setting = NotificationUserSetting.get_setting_object('NOTIFICATION_METHOD_MAIL', user=self.user) - self.assertEqual(str(test_setting), 'NOTIFICATION_METHOD_MAIL (for testuser): True') + NotificationUserSetting.set_setting( + 'NOTIFICATION_METHOD_MAIL', True, change_user=self.user, user=self.user + ) + test_setting = NotificationUserSetting.get_setting_object( + 'NOTIFICATION_METHOD_MAIL', user=self.user + ) + self.assertEqual( + str(test_setting), 'NOTIFICATION_METHOD_MAIL (for testuser): True' + ) class PluginSettingsApiTest(PluginMixin, InvenTreeAPITestCase): @@ -638,26 +616,39 @@ class PluginSettingsApiTest(PluginMixin, InvenTreeAPITestCase): registry.set_plugin_state('sample', True) # get data - url = reverse('api-plugin-setting-detail', kwargs={'plugin': 'sample', 'key': 'API_KEY'}) + url = reverse( + 'api-plugin-setting-detail', kwargs={'plugin': 'sample', 'key': 'API_KEY'} + ) response = self.get(url, expected_code=200) # check the right setting came through self.assertTrue(response.data['key'], 'API_KEY') self.assertTrue(response.data['plugin'], 'sample') self.assertTrue(response.data['type'], 'string') - self.assertTrue(response.data['description'], 'Key required for accessing external API') + self.assertTrue( + response.data['description'], 'Key required for accessing external API' + ) # Failure mode tests # Non-existent plugin - url = reverse('api-plugin-setting-detail', kwargs={'plugin': 'doesnotexist', 'key': 'doesnotmatter'}) + url = reverse( + 'api-plugin-setting-detail', + kwargs={'plugin': 'doesnotexist', 'key': 'doesnotmatter'}, + ) response = self.get(url, expected_code=404) self.assertIn("Plugin 'doesnotexist' not installed", str(response.data)) # Wrong key - url = reverse('api-plugin-setting-detail', kwargs={'plugin': 'sample', 'key': 'doesnotexsist'}) + url = reverse( + 'api-plugin-setting-detail', + kwargs={'plugin': 'sample', 'key': 'doesnotexsist'}, + ) response = self.get(url, expected_code=404) - self.assertIn("Plugin 'sample' has no setting matching 'doesnotexsist'", str(response.data)) + self.assertIn( + "Plugin 'sample' has no setting matching 'doesnotexsist'", + str(response.data), + ) def test_invalid_setting_key(self): """Test that an invalid setting key returns a 404.""" @@ -684,32 +675,30 @@ class WebhookMessageTests(TestCase): def test_missing_token(self): """Tests that token checks work.""" - response = self.client.post( - self.url, - content_type=CONTENT_TYPE_JSON, - ) + response = self.client.post(self.url, content_type=CONTENT_TYPE_JSON) assert response.status_code == HTTPStatus.FORBIDDEN assert ( - json.loads(response.content)['detail'] == WebhookView.model_class.MESSAGE_TOKEN_ERROR + json.loads(response.content)['detail'] + == WebhookView.model_class.MESSAGE_TOKEN_ERROR ) def test_bad_token(self): """Test that a wrong token is not working.""" response = self.client.post( - self.url, - content_type=CONTENT_TYPE_JSON, - **{'HTTP_TOKEN': '1234567fghj'}, + self.url, content_type=CONTENT_TYPE_JSON, **{'HTTP_TOKEN': '1234567fghj'} ) assert response.status_code == HTTPStatus.FORBIDDEN - assert (json.loads(response.content)['detail'] == WebhookView.model_class.MESSAGE_TOKEN_ERROR) + assert ( + json.loads(response.content)['detail'] + == WebhookView.model_class.MESSAGE_TOKEN_ERROR + ) def test_bad_url(self): """Test that a wrongly formed url is not working.""" response = self.client.post( - '/api/webhook/1234/', - content_type=CONTENT_TYPE_JSON, + '/api/webhook/1234/', content_type=CONTENT_TYPE_JSON ) assert response.status_code == HTTPStatus.NOT_FOUND @@ -725,7 +714,8 @@ class WebhookMessageTests(TestCase): assert response.status_code == HTTPStatus.NOT_ACCEPTABLE assert ( - json.loads(response.content)['detail'] == 'Expecting property name enclosed in double quotes' + json.loads(response.content)['detail'] + == 'Expecting property name enclosed in double quotes' ) def test_success_no_token_check(self): @@ -735,10 +725,7 @@ class WebhookMessageTests(TestCase): self.endpoint_def.save() # check - response = self.client.post( - self.url, - content_type=CONTENT_TYPE_JSON, - ) + response = self.client.post(self.url, content_type=CONTENT_TYPE_JSON) assert response.status_code == HTTPStatus.OK assert str(response.content, 'utf-8') == WebhookView.model_class.MESSAGE_OK @@ -751,13 +738,13 @@ class WebhookMessageTests(TestCase): self.endpoint_def.save() # check - response = self.client.post( - self.url, - content_type=CONTENT_TYPE_JSON, - ) + response = self.client.post(self.url, content_type=CONTENT_TYPE_JSON) assert response.status_code == HTTPStatus.FORBIDDEN - assert (json.loads(response.content)['detail'] == WebhookView.model_class.MESSAGE_TOKEN_ERROR) + assert ( + json.loads(response.content)['detail'] + == WebhookView.model_class.MESSAGE_TOKEN_ERROR + ) def test_success_hmac(self): """Test with a valid HMAC provided.""" @@ -797,9 +784,7 @@ class WebhookMessageTests(TestCase): class NotificationTest(InvenTreeAPITestCase): """Tests for NotificationEntry.""" - fixtures = [ - 'users', - ] + fixtures = ['users'] def test_check_notification_entries(self): """Test that notification entries can be created.""" @@ -832,7 +817,10 @@ class NotificationTest(InvenTreeAPITestCase): self.assertIn('GET', response.data['actions']) self.assertNotIn('POST', response.data['actions']) - self.assertEqual(response.data['description'], 'List view for all notifications of the current user.') + self.assertEqual( + response.data['description'], + 'List view for all notifications of the current user.', + ) # POST action should fail (not allowed) response = self.post(url, {}, expected_code=405) @@ -867,13 +855,7 @@ class NotificationTest(InvenTreeAPITestCase): ntf.save() # Read out via API again - response = self.get( - url, - { - 'read': True, - }, - expected_code=200 - ) + response = self.get(url, {'read': True}, expected_code=200) # Check validity of returned data self.assertEqual(len(response.data), 3) @@ -882,15 +864,7 @@ class NotificationTest(InvenTreeAPITestCase): # Now, let's bulk delete all 'unread' notifications via the API, # but only associated with the logged in user - response = self.delete( - url, - { - 'filters': { - 'read': False, - } - }, - expected_code=204, - ) + response = self.delete(url, {'filters': {'read': False}}, expected_code=204) # Only 7 notifications should have been deleted, # as the notifications associated with other users must remain untouched @@ -907,13 +881,17 @@ class CommonTest(InvenTreeAPITestCase): from plugin import registry # set flag true - common.models.InvenTreeSetting.set_setting('SERVER_RESTART_REQUIRED', True, None) + common.models.InvenTreeSetting.set_setting( + 'SERVER_RESTART_REQUIRED', True, None + ) # reload the app registry.reload_plugins() # now it should be false again - self.assertFalse(common.models.InvenTreeSetting.get_setting('SERVER_RESTART_REQUIRED')) + self.assertFalse( + common.models.InvenTreeSetting.get_setting('SERVER_RESTART_REQUIRED') + ) def test_config_api(self): """Test config URLs.""" @@ -926,8 +904,13 @@ class CommonTest(InvenTreeAPITestCase): # Successful checks data = [ - self.get(reverse('api-config-list'), expected_code=200).data[0], # list endpoint - self.get(reverse('api-config-detail', kwargs={'key': 'INVENTREE_DEBUG'}), expected_code=200).data, # detail endpoint + self.get(reverse('api-config-list'), expected_code=200).data[ + 0 + ], # list endpoint + self.get( + reverse('api-config-detail', kwargs={'key': 'INVENTREE_DEBUG'}), + expected_code=200, + ).data, # detail endpoint ] for item in data: @@ -956,21 +939,33 @@ class CommonTest(InvenTreeAPITestCase): self.assertEqual(response.data[0]['key'], 'EXPERIMENTAL') self.assertTrue(response.data[0]['conditions']) - response = self.get(reverse('api-flag-detail', kwargs={'key': 'EXPERIMENTAL'}), expected_code=200) + response = self.get( + reverse('api-flag-detail', kwargs={'key': 'EXPERIMENTAL'}), + expected_code=200, + ) self.assertEqual(len(response.data), 3) self.assertEqual(response.data['key'], 'EXPERIMENTAL') self.assertTrue(response.data['conditions']) # Try without param -> false - response = self.get(reverse('api-flag-detail', kwargs={'key': 'NEXT_GEN'}), expected_code=200) + response = self.get( + reverse('api-flag-detail', kwargs={'key': 'NEXT_GEN'}), expected_code=200 + ) self.assertFalse(response.data['state']) # Try with param -> true - response = self.get(reverse('api-flag-detail', kwargs={'key': 'NEXT_GEN'}), {'ngen': ''}, expected_code=200) + response = self.get( + reverse('api-flag-detail', kwargs={'key': 'NEXT_GEN'}), + {'ngen': ''}, + expected_code=200, + ) self.assertTrue(response.data['state']) # Try non existent flag - response = self.get(reverse('api-flag-detail', kwargs={'key': 'NON_EXISTENT'}), expected_code=404) + response = self.get( + reverse('api-flag-detail', kwargs={'key': 'NON_EXISTENT'}), + expected_code=404, + ) # Turn into normal user again self.user.is_superuser = False @@ -1052,10 +1047,12 @@ class NotesImageTest(InvenTreeAPITestCase): response = self.post( reverse('api-notes-image-list'), data={ - 'image': SimpleUploadedFile('test.txt', b"this is not an image file", content_type='text/plain'), + 'image': SimpleUploadedFile( + 'test.txt', b"this is not an image file", content_type='text/plain' + ) }, format='multipart', - expected_code=400 + expected_code=400, ) self.assertIn("Upload a valid image", str(response.data['image'])) @@ -1064,7 +1061,9 @@ class NotesImageTest(InvenTreeAPITestCase): response = self.post( reverse('api-notes-image-list'), data={ - 'image': SimpleUploadedFile('test.png', b"this is not an image file", content_type='image/png'), + 'image': SimpleUploadedFile( + 'test.png', b"this is not an image file", content_type='image/png' + ) }, format='multipart', expected_code=400, @@ -1089,10 +1088,12 @@ class NotesImageTest(InvenTreeAPITestCase): self.post( reverse('api-notes-image-list'), data={ - 'image': SimpleUploadedFile('test.png', contents, content_type='image/png'), + 'image': SimpleUploadedFile( + 'test.png', contents, content_type='image/png' + ) }, format='multipart', - expected_code=201 + expected_code=201, ) # Check that a new file has been created @@ -1136,7 +1137,7 @@ class ProjectCodesTest(InvenTreeAPITestCase): # Delete it self.delete( reverse('api-project-code-detail', kwargs={'pk': code.pk}), - expected_code=204 + expected_code=204, ) # Check it is gone @@ -1147,25 +1148,22 @@ class ProjectCodesTest(InvenTreeAPITestCase): # Create a new project code response = self.post( self.url, - data={ - 'code': 'PRJ-001', - 'description': 'Test project code', - }, - expected_code=400 + data={'code': 'PRJ-001', 'description': 'Test project code'}, + expected_code=400, ) - self.assertIn('project code with this Project Code already exists', str(response.data['code'])) + self.assertIn( + 'project code with this Project Code already exists', + str(response.data['code']), + ) def test_write_access(self): """Test that non-staff users have read-only access""" # By default user has staff access, can create a new project code response = self.post( self.url, - data={ - 'code': 'PRJ-xxx', - 'description': 'Test project code', - }, - expected_code=201 + data={'code': 'PRJ-xxx', 'description': 'Test project code'}, + expected_code=201, ) pk = response.data['pk'] @@ -1173,10 +1171,8 @@ class ProjectCodesTest(InvenTreeAPITestCase): # Test we can edit, also response = self.patch( reverse('api-project-code-detail', kwargs={'pk': pk}), - data={ - 'code': 'PRJ-999', - }, - expected_code=200 + data={'code': 'PRJ-999'}, + expected_code=200, ) self.assertEqual(response.data['code'], 'PRJ-999') @@ -1188,20 +1184,15 @@ class ProjectCodesTest(InvenTreeAPITestCase): # As user does not have staff access, should return 403 for list endpoint response = self.post( self.url, - data={ - 'code': 'PRJ-123', - 'description': 'Test project code' - }, - expected_code=403 + data={'code': 'PRJ-123', 'description': 'Test project code'}, + expected_code=403, ) # Should also return 403 for detail endpoint response = self.patch( reverse('api-project-code-detail', kwargs={'pk': pk}), - data={ - 'code': 'PRJ-999', - }, - expected_code=403 + data={'code': 'PRJ-999'}, + expected_code=403, ) @@ -1219,8 +1210,14 @@ class CustomUnitAPITest(InvenTreeAPITestCase): super().setUpTestData() units = [ - CustomUnit(name='metres_per_amp', definition='meter / ampere', symbol='m/A'), - CustomUnit(name='hectares_per_second', definition='hectares per second', symbol='ha/s'), + CustomUnit( + name='metres_per_amp', definition='meter / ampere', symbol='m/A' + ), + CustomUnit( + name='hectares_per_second', + definition='hectares per second', + symbol='ha/s', + ), ] CustomUnit.objects.bulk_create(units) @@ -1240,10 +1237,8 @@ class CustomUnitAPITest(InvenTreeAPITestCase): self.patch( reverse('api-custom-unit-detail', kwargs={'pk': unit.pk}), - { - 'name': 'new_unit_name', - }, - expected_code=403 + {'name': 'new_unit_name'}, + expected_code=403, ) # Ok, what if we have permission? @@ -1252,9 +1247,7 @@ class CustomUnitAPITest(InvenTreeAPITestCase): self.patch( reverse('api-custom-unit-detail', kwargs={'pk': unit.pk}), - { - 'name': 'new_unit_name', - }, + {'name': 'new_unit_name'}, # expected_code=200 ) @@ -1269,21 +1262,9 @@ class CustomUnitAPITest(InvenTreeAPITestCase): self.user.save() # Test invalid 'name' values (must be valid identifier) - invalid_name_values = [ - '1', - '1abc', - 'abc def', - 'abc-def', - 'abc.def', - ] + invalid_name_values = ['1', '1abc', 'abc def', 'abc-def', 'abc.def'] url = reverse('api-custom-unit-detail', kwargs={'pk': unit.pk}) for name in invalid_name_values: - self.patch( - url, - { - 'name': name, - }, - expected_code=400 - ) + self.patch(url, {'name': name}, expected_code=400) diff --git a/InvenTree/common/urls.py b/InvenTree/common/urls.py index 4803d86236..95f89631df 100644 --- a/InvenTree/common/urls.py +++ b/InvenTree/common/urls.py @@ -1,4 +1,3 @@ """URL lookup for common views.""" -common_urls = [ -] +common_urls = [] diff --git a/InvenTree/common/views.py b/InvenTree/common/views.py index 2eeb48b5df..79e6d63ddc 100644 --- a/InvenTree/common/views.py +++ b/InvenTree/common/views.py @@ -81,11 +81,7 @@ class FileManagementFormView(MultiStepFormView): ('fields', forms.MatchFieldForm), ('items', forms.MatchItemForm), ] - form_steps_description = [ - _("Upload File"), - _("Match Fields"), - _("Match Items"), - ] + form_steps_description = [_("Upload File"), _("Match Fields"), _("Match Items")] media_folder = 'file_upload/' extra_context_data = {} @@ -95,8 +91,12 @@ class FileManagementFormView(MultiStepFormView): super().__init__(self, *args, **kwargs) # Check for file manager class - if not hasattr(self, 'file_manager_class') and not issubclass(self.file_manager_class, FileManager): - raise NotImplementedError('A subclass of a file manager class needs to be set!') + if not hasattr(self, 'file_manager_class') and not issubclass( + self.file_manager_class, FileManager + ): + raise NotImplementedError( + 'A subclass of a file manager class needs to be set!' + ) def get_context_data(self, form=None, **kwargs): """Handle context data.""" @@ -106,7 +106,6 @@ class FileManagementFormView(MultiStepFormView): context = super().get_context_data(form=form, **kwargs) if self.steps.current in ('fields', 'items'): - # Get columns and row data self.columns = self.file_manager.columns() self.rows = self.file_manager.rows() @@ -140,7 +139,9 @@ class FileManagementFormView(MultiStepFormView): # Get file file = upload_files.get('upload-file', None) if file: - self.file_manager = self.file_manager_class(file=file, name=self.name) + self.file_manager = self.file_manager_class( + file=file, name=self.name + ) def get_form_kwargs(self, step=None): """Update kwargs to dynamically build forms.""" @@ -150,15 +151,11 @@ class FileManagementFormView(MultiStepFormView): if step == 'upload': # Dynamically build upload form if self.name: - kwargs = { - 'name': self.name - } + kwargs = {'name': self.name} return kwargs elif step == 'fields': # Dynamically build match field form - kwargs = { - 'file_manager': self.file_manager - } + kwargs = {'file_manager': self.file_manager} return kwargs elif step == 'items': # Dynamically build match item form @@ -206,7 +203,6 @@ class FileManagementFormView(MultiStepFormView): self.row_data = {} for item, value in form_data.items(): - # Column names as passed as col_name_ where idx is an integer # Extract the column names @@ -220,7 +216,6 @@ class FileManagementFormView(MultiStepFormView): # Extract the column selections (in the 'select fields' view) if item.startswith('fields-'): - try: col_name = item.replace('fields-', '') except ValueError: @@ -258,10 +253,7 @@ class FileManagementFormView(MultiStepFormView): self.columns = [] for idx, value in self.column_names.items(): - header = ({ - 'name': value, - 'guess': self.column_selections.get(idx, ''), - }) + header = {'name': value, 'guess': self.column_selections.get(idx, '')} self.columns.append(header) if self.row_data: @@ -280,18 +272,10 @@ class FileManagementFormView(MultiStepFormView): 'guess': self.column_selections[idx], } - cell_data = { - 'cell': item, - 'idx': idx, - 'column': column_data, - } + cell_data = {'cell': item, 'idx': idx, 'column': column_data} data.append(cell_data) - row = { - 'index': row_idx, - 'data': data, - 'errors': {}, - } + row = {'index': row_idx, 'data': data, 'errors': {}} self.rows.append(row) @@ -344,11 +328,7 @@ class FileManagementFormView(MultiStepFormView): try: if idx not in items: # Insert into items - items.update({ - idx: { - self.form_field_map[field]: form_value, - } - }) + items.update({idx: {self.form_field_map[field]: form_value}}) else: # Update items items[idx][self.form_field_map[field]] = form_value @@ -383,14 +363,15 @@ class FileManagementFormView(MultiStepFormView): duplicates = [] for col in self.column_names: - if col in self.column_selections: guess = self.column_selections[col] else: guess = None if guess: - n = list(self.column_selections.values()).count(self.column_selections[col]) + n = list(self.column_selections.values()).count( + self.column_selections[col] + ) if n > 1 and self.column_selections[col] not in duplicates: duplicates.append(self.column_selections[col]) @@ -459,7 +440,9 @@ class FileManagementAjaxView(AjaxView): wizard_back = self.request.POST.get('act-btn_back', None) if wizard_back: back_step_index = self.get_step_index() - 1 - self.storage.current_step = list(self.get_form_list().keys())[back_step_index] + self.storage.current_step = list(self.get_form_list().keys())[ + back_step_index + ] return self.renderJsonResponse(request, data={'form_valid': None}) # validate form @@ -499,13 +482,19 @@ class FileManagementAjaxView(AjaxView): data = {} self.setTemplate() - return super().renderJsonResponse(request, form=form, data=data, context=context) + return super().renderJsonResponse( + request, form=form, data=data, context=context + ) def get_data(self) -> dict: """Get extra context data.""" data = super().get_data() data['hideErrorMessage'] = '1' # hide the error - buttons = [{'name': 'back', 'title': _('Previous Step')}] if self.get_step_index() > 0 else [] + buttons = ( + [{'name': 'back', 'title': _('Previous Step')}] + if self.get_step_index() > 0 + else [] + ) data['buttons'] = buttons # set buttons return data diff --git a/InvenTree/company/admin.py b/InvenTree/company/admin.py index 5cf7bbda87..ff25a2e086 100644 --- a/InvenTree/company/admin.py +++ b/InvenTree/company/admin.py @@ -9,9 +9,16 @@ from import_export.fields import Field from InvenTree.admin import InvenTreeResource from part.models import Part -from .models import (Address, Company, Contact, ManufacturerPart, - ManufacturerPartAttachment, ManufacturerPartParameter, - SupplierPart, SupplierPriceBreak) +from .models import ( + Address, + Company, + Contact, + ManufacturerPart, + ManufacturerPartAttachment, + ManufacturerPartParameter, + SupplierPart, + SupplierPriceBreak, +) class CompanyResource(InvenTreeResource): @@ -19,6 +26,7 @@ class CompanyResource(InvenTreeResource): class Meta: """Metaclass defines extra options""" + model = Company skip_unchanged = True report_skipped = False @@ -32,10 +40,7 @@ class CompanyAdmin(ImportExportModelAdmin): list_display = ('name', 'website', 'contact') - search_fields = [ - 'name', - 'description', - ] + search_fields = ['name', 'description'] class SupplierPartResource(InvenTreeResource): @@ -43,6 +48,7 @@ class SupplierPartResource(InvenTreeResource): class Meta: """Metaclass defines extra admin options""" + model = SupplierPart skip_unchanged = True report_skipped = True @@ -70,18 +76,11 @@ class SupplierPartAdmin(ImportExportModelAdmin): list_display = ('part', 'supplier', 'SKU') - search_fields = [ - 'supplier__name', - 'part__name', - 'manufacturer_part__MPN', - 'SKU', - ] + search_fields = ['supplier__name', 'part__name', 'manufacturer_part__MPN', 'SKU'] - inlines = [ - SupplierPriceBreakInline, - ] + inlines = [SupplierPriceBreakInline] - autocomplete_fields = ('part', 'supplier', 'manufacturer_part',) + autocomplete_fields = ('part', 'supplier', 'manufacturer_part') class ManufacturerPartResource(InvenTreeResource): @@ -89,6 +88,7 @@ class ManufacturerPartResource(InvenTreeResource): class Meta: """Metaclass defines extra admin options""" + model = ManufacturerPart skip_unchanged = True report_skipped = True @@ -98,7 +98,9 @@ class ManufacturerPartResource(InvenTreeResource): part_name = Field(attribute='part__full_name', readonly=True) - manufacturer = Field(attribute='manufacturer', widget=widgets.ForeignKeyWidget(Company)) + manufacturer = Field( + attribute='manufacturer', widget=widgets.ForeignKeyWidget(Company) + ) manufacturer_name = Field(attribute='manufacturer__name', readonly=True) @@ -110,13 +112,9 @@ class ManufacturerPartAdmin(ImportExportModelAdmin): list_display = ('part', 'manufacturer', 'MPN') - search_fields = [ - 'manufacturer__name', - 'part__name', - 'MPN', - ] + search_fields = ['manufacturer__name', 'part__name', 'MPN'] - autocomplete_fields = ('part', 'manufacturer',) + autocomplete_fields = ('part', 'manufacturer') class ManufacturerPartAttachmentAdmin(ImportExportModelAdmin): @@ -132,6 +130,7 @@ class ManufacturerPartParameterResource(InvenTreeResource): class Meta: """Metaclass defines extra admin options""" + model = ManufacturerPartParameter skip_unchanged = True report_skipped = True @@ -145,11 +144,7 @@ class ManufacturerPartParameterAdmin(ImportExportModelAdmin): list_display = ('manufacturer_part', 'name', 'value') - search_fields = [ - 'manufacturer_part__manufacturer__name', - 'name', - 'value' - ] + search_fields = ['manufacturer_part__manufacturer__name', 'name', 'value'] autocomplete_fields = ('manufacturer_part',) @@ -159,6 +154,7 @@ class SupplierPriceBreakResource(InvenTreeResource): class Meta: """Metaclass defines extra admin options""" + model = SupplierPriceBreak skip_unchanged = True report_skipped = False @@ -192,6 +188,7 @@ class AddressResource(InvenTreeResource): class Meta: """Metaclass defining extra options""" + model = Address skip_unchanged = True report_skipped = False @@ -207,11 +204,7 @@ class AddressAdmin(ImportExportModelAdmin): list_display = ('company', 'line1', 'postal_code', 'country') - search_fields = [ - 'company', - 'country', - 'postal_code', - ] + search_fields = ['company', 'country', 'postal_code'] class ContactResource(InvenTreeResource): @@ -219,6 +212,7 @@ class ContactResource(InvenTreeResource): class Meta: """Metaclass defining extra options""" + model = Contact skip_unchanged = True report_skipped = False @@ -234,11 +228,7 @@ class ContactAdmin(ImportExportModelAdmin): list_display = ('company', 'name', 'role', 'email', 'phone') - search_fields = [ - 'company', - 'name', - 'email', - ] + search_fields = ['company', 'name', 'email'] admin.site.register(Company, CompanyAdmin) diff --git a/InvenTree/company/api.py b/InvenTree/company/api.py index 51c27f2279..b35ce16d07 100644 --- a/InvenTree/company/api.py +++ b/InvenTree/company/api.py @@ -6,23 +6,37 @@ from django.urls import include, path, re_path from django_filters import rest_framework as rest_filters import part.models -from InvenTree.api import (AttachmentMixin, ListCreateDestroyAPIView, - MetadataView) -from InvenTree.filters import (ORDER_FILTER, SEARCH_ORDER_FILTER, - SEARCH_ORDER_FILTER_ALIAS) +from InvenTree.api import AttachmentMixin, ListCreateDestroyAPIView, MetadataView +from InvenTree.filters import ( + ORDER_FILTER, + SEARCH_ORDER_FILTER, + SEARCH_ORDER_FILTER_ALIAS, +) from InvenTree.helpers import str2bool from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI -from .models import (Address, Company, CompanyAttachment, Contact, - ManufacturerPart, ManufacturerPartAttachment, - ManufacturerPartParameter, SupplierPart, - SupplierPriceBreak) -from .serializers import (AddressSerializer, CompanyAttachmentSerializer, - CompanySerializer, ContactSerializer, - ManufacturerPartAttachmentSerializer, - ManufacturerPartParameterSerializer, - ManufacturerPartSerializer, SupplierPartSerializer, - SupplierPriceBreakSerializer) +from .models import ( + Address, + Company, + CompanyAttachment, + Contact, + ManufacturerPart, + ManufacturerPartAttachment, + ManufacturerPartParameter, + SupplierPart, + SupplierPriceBreak, +) +from .serializers import ( + AddressSerializer, + CompanyAttachmentSerializer, + CompanySerializer, + ContactSerializer, + ManufacturerPartAttachmentSerializer, + ManufacturerPartParameterSerializer, + ManufacturerPartSerializer, + SupplierPartSerializer, + SupplierPriceBreakSerializer, +) class CompanyList(ListCreateAPI): @@ -46,24 +60,11 @@ class CompanyList(ListCreateAPI): filter_backends = SEARCH_ORDER_FILTER - filterset_fields = [ - 'is_customer', - 'is_manufacturer', - 'is_supplier', - 'name', - ] + filterset_fields = ['is_customer', 'is_manufacturer', 'is_supplier', 'name'] - search_fields = [ - 'name', - 'description', - 'website', - ] + search_fields = ['name', 'description', 'website'] - ordering_fields = [ - 'name', - 'parts_supplied', - 'parts_manufactured', - ] + ordering_fields = ['name', 'parts_supplied', 'parts_manufactured'] ordering = 'name' @@ -88,9 +89,7 @@ class CompanyAttachmentList(AttachmentMixin, ListCreateDestroyAPIView): queryset = CompanyAttachment.objects.all() serializer_class = CompanyAttachmentSerializer - filterset_fields = [ - 'company', - ] + filterset_fields = ['company'] class CompanyAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI): @@ -108,18 +107,11 @@ class ContactList(ListCreateDestroyAPIView): filter_backends = SEARCH_ORDER_FILTER - filterset_fields = [ - 'company', - ] + filterset_fields = ['company'] - search_fields = [ - 'company__name', - 'name', - ] + search_fields = ['company__name', 'name'] - ordering_fields = [ - 'name', - ] + ordering_fields = ['name'] ordering = 'name' @@ -139,13 +131,9 @@ class AddressList(ListCreateDestroyAPIView): filter_backends = SEARCH_ORDER_FILTER - filterset_fields = [ - 'company', - ] + filterset_fields = ['company'] - ordering_fields = [ - 'title', - ] + ordering_fields = ['title'] ordering = 'title' @@ -164,13 +152,7 @@ class ManufacturerPartFilter(rest_filters.FilterSet): """Metaclass options.""" model = ManufacturerPart - fields = [ - 'manufacturer', - 'MPN', - 'part', - 'tags__name', - 'tags__slug', - ] + fields = ['manufacturer', 'MPN', 'part', 'tags__name', 'tags__slug'] # Filter by 'active' status of linked part active = rest_filters.BooleanFilter(field_name='part__active') @@ -184,10 +166,7 @@ class ManufacturerPartList(ListCreateDestroyAPIView): """ queryset = ManufacturerPart.objects.all().prefetch_related( - 'part', - 'manufacturer', - 'supplier_parts', - 'tags', + 'part', 'manufacturer', 'supplier_parts', 'tags' ) serializer_class = ManufacturerPartSerializer @@ -200,7 +179,9 @@ class ManufacturerPartList(ListCreateDestroyAPIView): params = self.request.query_params kwargs['part_detail'] = str2bool(params.get('part_detail', None)) - kwargs['manufacturer_detail'] = str2bool(params.get('manufacturer_detail', None)) + kwargs['manufacturer_detail'] = str2bool( + params.get('manufacturer_detail', None) + ) kwargs['pretty'] = str2bool(params.get('pretty', None)) except AttributeError: pass @@ -241,9 +222,7 @@ class ManufacturerPartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView): queryset = ManufacturerPartAttachment.objects.all() serializer_class = ManufacturerPartAttachmentSerializer - filterset_fields = [ - 'manufacturer_part', - ] + filterset_fields = ['manufacturer_part'] class ManufacturerPartAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI): @@ -258,17 +237,17 @@ class ManufacturerPartParameterFilter(rest_filters.FilterSet): class Meta: """Metaclass options""" + model = ManufacturerPartParameter - fields = [ - 'name', - 'value', - 'units', - 'manufacturer_part', - ] + fields = ['name', 'value', 'units', 'manufacturer_part'] - manufacturer = rest_filters.ModelChoiceFilter(queryset=Company.objects.all(), field_name='manufacturer_part__manufacturer') + manufacturer = rest_filters.ModelChoiceFilter( + queryset=Company.objects.all(), field_name='manufacturer_part__manufacturer' + ) - part = rest_filters.ModelChoiceFilter(queryset=part.models.Part.objects.all(), field_name='manufacturer_part__part') + part = rest_filters.ModelChoiceFilter( + queryset=part.models.Part.objects.all(), field_name='manufacturer_part__part' + ) class ManufacturerPartParameterList(ListCreateDestroyAPIView): @@ -284,9 +263,7 @@ class ManufacturerPartParameterList(ListCreateDestroyAPIView): try: params = self.request.query_params - optional_fields = [ - 'manufacturer_part_detail', - ] + optional_fields = ['manufacturer_part_detail'] for key in optional_fields: kwargs[key] = str2bool(params.get(key, None)) @@ -300,11 +277,7 @@ class ManufacturerPartParameterList(ListCreateDestroyAPIView): filter_backends = SEARCH_ORDER_FILTER - search_fields = [ - 'name', - 'value', - 'units', - ] + search_fields = ['name', 'value', 'units'] class ManufacturerPartParameterDetail(RetrieveUpdateDestroyAPI): @@ -337,7 +310,7 @@ class SupplierPartFilter(rest_filters.FilterSet): MPN = rest_filters.CharFilter( label='Manufacturer Part Number', field_name='manufacturer_part__MPN', - lookup_expr='iexact' + lookup_expr='iexact', ) @@ -348,9 +321,7 @@ class SupplierPartList(ListCreateDestroyAPIView): - POST: Create a new SupplierPart object """ - queryset = SupplierPart.objects.all().prefetch_related( - 'tags', - ) + queryset = SupplierPart.objects.all().prefetch_related('tags') filterset_class = SupplierPartFilter def get_queryset(self, *args, **kwargs): @@ -376,7 +347,9 @@ class SupplierPartList(ListCreateDestroyAPIView): company = params.get('company', None) if company is not None: - queryset = queryset.filter(Q(manufacturer_part__manufacturer=company) | Q(supplier=company)).distinct() + queryset = queryset.filter( + Q(manufacturer_part__manufacturer=company) | Q(supplier=company) + ).distinct() return queryset @@ -387,7 +360,9 @@ class SupplierPartList(ListCreateDestroyAPIView): params = self.request.query_params kwargs['part_detail'] = str2bool(params.get('part_detail', None)) kwargs['supplier_detail'] = str2bool(params.get('supplier_detail', True)) - kwargs['manufacturer_detail'] = str2bool(params.get('manufacturer_detail', None)) + kwargs['manufacturer_detail'] = str2bool( + params.get('manufacturer_detail', None) + ) kwargs['pretty'] = str2bool(params.get('pretty', None)) except AttributeError: pass @@ -446,8 +421,7 @@ class SupplierPartDetail(RetrieveUpdateDestroyAPI): queryset = SupplierPart.objects.all() serializer_class = SupplierPartSerializer - read_only_fields = [ - ] + read_only_fields = [] class SupplierPriceBreakFilter(rest_filters.FilterSet): @@ -457,10 +431,7 @@ class SupplierPriceBreakFilter(rest_filters.FilterSet): """Metaclass options""" model = SupplierPriceBreak - fields = [ - 'part', - 'quantity', - ] + fields = ['part', 'quantity'] base_part = rest_filters.ModelChoiceFilter( label='Base Part', @@ -469,9 +440,7 @@ class SupplierPriceBreakFilter(rest_filters.FilterSet): ) supplier = rest_filters.ModelChoiceFilter( - label='Supplier', - queryset=Company.objects.all(), - field_name='part__supplier', + label='Supplier', queryset=Company.objects.all(), field_name='part__supplier' ) @@ -503,9 +472,7 @@ class SupplierPriceBreakList(ListCreateAPI): filter_backends = ORDER_FILTER - ordering_fields = [ - 'quantity', - ] + ordering_fields = ['quantity'] ordering = 'quantity' @@ -518,37 +485,74 @@ class SupplierPriceBreakDetail(RetrieveUpdateDestroyAPI): manufacturer_part_api_urls = [ - # Base URL for ManufacturerPartAttachment API endpoints - re_path(r'^attachment/', include([ - path(r'/', ManufacturerPartAttachmentDetail.as_view(), name='api-manufacturer-part-attachment-detail'), - re_path(r'^$', ManufacturerPartAttachmentList.as_view(), name='api-manufacturer-part-attachment-list'), - ])), - - re_path(r'^parameter/', include([ - path(r'/', ManufacturerPartParameterDetail.as_view(), name='api-manufacturer-part-parameter-detail'), - - # Catch anything else - re_path(r'^.*$', ManufacturerPartParameterList.as_view(), name='api-manufacturer-part-parameter-list'), - ])), - - re_path(r'^(?P\d+)/?', include([ - re_path('^metadata/', MetadataView.as_view(), {'model': ManufacturerPart}, name='api-manufacturer-part-metadata'), - re_path('^.*$', ManufacturerPartDetail.as_view(), name='api-manufacturer-part-detail'), - ])), - + re_path( + r'^attachment/', + include([ + path( + r'/', + ManufacturerPartAttachmentDetail.as_view(), + name='api-manufacturer-part-attachment-detail', + ), + re_path( + r'^$', + ManufacturerPartAttachmentList.as_view(), + name='api-manufacturer-part-attachment-list', + ), + ]), + ), + re_path( + r'^parameter/', + include([ + path( + r'/', + ManufacturerPartParameterDetail.as_view(), + name='api-manufacturer-part-parameter-detail', + ), + # Catch anything else + re_path( + r'^.*$', + ManufacturerPartParameterList.as_view(), + name='api-manufacturer-part-parameter-list', + ), + ]), + ), + re_path( + r'^(?P\d+)/?', + include([ + re_path( + '^metadata/', + MetadataView.as_view(), + {'model': ManufacturerPart}, + name='api-manufacturer-part-metadata', + ), + re_path( + '^.*$', + ManufacturerPartDetail.as_view(), + name='api-manufacturer-part-detail', + ), + ]), + ), # Catch anything else re_path(r'^.*$', ManufacturerPartList.as_view(), name='api-manufacturer-part-list'), ] supplier_part_api_urls = [ - - re_path(r'^(?P\d+)/?', include([ - re_path('^metadata/', MetadataView.as_view(), {'model': SupplierPart}, name='api-supplier-part-metadata'), - re_path('^.*$', SupplierPartDetail.as_view(), name='api-supplier-part-detail'), - ])), - + re_path( + r'^(?P\d+)/?', + include([ + re_path( + '^metadata/', + MetadataView.as_view(), + {'model': SupplierPart}, + name='api-supplier-part-metadata', + ), + re_path( + '^.*$', SupplierPartDetail.as_view(), name='api-supplier-part-detail' + ), + ]), + ), # Catch anything else re_path(r'^.*$', SupplierPartList.as_view(), name='api-supplier-part-list'), ] @@ -556,39 +560,74 @@ supplier_part_api_urls = [ company_api_urls = [ re_path(r'^part/manufacturer/', include(manufacturer_part_api_urls)), - re_path(r'^part/', include(supplier_part_api_urls)), - # Supplier price breaks - re_path(r'^price-break/', include([ - - re_path(r'^(?P\d+)/?', SupplierPriceBreakDetail.as_view(), name='api-part-supplier-price-detail'), - re_path(r'^.*$', SupplierPriceBreakList.as_view(), name='api-part-supplier-price-list'), - ])), - - re_path(r'^(?P\d+)/?', include([ - re_path(r'^metadata/', MetadataView.as_view(), {'model': Company}, name='api-company-metadata'), - re_path(r'^.*$', CompanyDetail.as_view(), name='api-company-detail'), - ])), - - re_path(r'^attachment/', include([ - path(r'/', CompanyAttachmentDetail.as_view(), name='api-company-attachment-detail'), - re_path(r'^$', CompanyAttachmentList.as_view(), name='api-company-attachment-list'), - ])), - - re_path(r'^contact/', include([ - re_path(r'^(?P\d+)/?', include([ - re_path('^metadata/', MetadataView.as_view(), {'model': Contact}, name='api-contact-metadata'), - re_path('^.*$', ContactDetail.as_view(), name='api-contact-detail'), - ])), - re_path(r'^.*$', ContactList.as_view(), name='api-contact-list'), - ])), - - re_path(r'^address/', include([ - path('/', AddressDetail.as_view(), name='api-address-detail'), - re_path(r'^.*$', AddressList.as_view(), name='api-address-list'), - ])), - + re_path( + r'^price-break/', + include([ + re_path( + r'^(?P\d+)/?', + SupplierPriceBreakDetail.as_view(), + name='api-part-supplier-price-detail', + ), + re_path( + r'^.*$', + SupplierPriceBreakList.as_view(), + name='api-part-supplier-price-list', + ), + ]), + ), + re_path( + r'^(?P\d+)/?', + include([ + re_path( + r'^metadata/', + MetadataView.as_view(), + {'model': Company}, + name='api-company-metadata', + ), + re_path(r'^.*$', CompanyDetail.as_view(), name='api-company-detail'), + ]), + ), + re_path( + r'^attachment/', + include([ + path( + r'/', + CompanyAttachmentDetail.as_view(), + name='api-company-attachment-detail', + ), + re_path( + r'^$', + CompanyAttachmentList.as_view(), + name='api-company-attachment-list', + ), + ]), + ), + re_path( + r'^contact/', + include([ + re_path( + r'^(?P\d+)/?', + include([ + re_path( + '^metadata/', + MetadataView.as_view(), + {'model': Contact}, + name='api-contact-metadata', + ), + re_path('^.*$', ContactDetail.as_view(), name='api-contact-detail'), + ]), + ), + re_path(r'^.*$', ContactList.as_view(), name='api-contact-list'), + ]), + ), + re_path( + r'^address/', + include([ + path('/', AddressDetail.as_view(), name='api-address-detail'), + re_path(r'^.*$', AddressList.as_view(), name='api-address-list'), + ]), + ), re_path(r'^.*$', CompanyList.as_view(), name='api-company-list'), - ] diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index 154de622ca..c4e4d1f5cc 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -29,8 +29,12 @@ import InvenTree.tasks import InvenTree.validators from common.settings import currency_code_default from InvenTree.fields import InvenTreeURLField, RoundingDecimalField -from InvenTree.models import (InvenTreeAttachment, InvenTreeBarcodeMixin, - InvenTreeNotesMixin, MetadataMixin) +from InvenTree.models import ( + InvenTreeAttachment, + InvenTreeBarcodeMixin, + InvenTreeNotesMixin, + MetadataMixin, +) from InvenTree.status_codes import PurchaseOrderStatusGroups @@ -87,7 +91,8 @@ class Company(InvenTreeNotesMixin, MetadataMixin, models.Model): class Meta: """Metaclass defines extra model options""" - ordering = ['name', ] + + ordering = ['name'] constraints = [ UniqueConstraint(fields=['name', 'email'], name='unique_name_email_pair') ] @@ -98,9 +103,12 @@ class Company(InvenTreeNotesMixin, MetadataMixin, models.Model): """Return the API URL associated with the Company model""" return reverse('api-company-list') - name = models.CharField(max_length=100, blank=False, - help_text=_('Company name'), - verbose_name=_('Company name')) + name = models.CharField( + max_length=100, + blank=False, + help_text=_('Company name'), + verbose_name=_('Company name'), + ) description = models.CharField( max_length=500, @@ -110,41 +118,62 @@ class Company(InvenTreeNotesMixin, MetadataMixin, models.Model): ) website = InvenTreeURLField( - blank=True, - verbose_name=_('Website'), - help_text=_('Company website URL') + blank=True, verbose_name=_('Website'), help_text=_('Company website URL') ) - phone = models.CharField(max_length=50, - verbose_name=_('Phone number'), - blank=True, help_text=_('Contact phone number')) + phone = models.CharField( + max_length=50, + verbose_name=_('Phone number'), + blank=True, + help_text=_('Contact phone number'), + ) - email = models.EmailField(blank=True, null=True, - verbose_name=_('Email'), help_text=_('Contact email address')) + email = models.EmailField( + blank=True, + null=True, + verbose_name=_('Email'), + help_text=_('Contact email address'), + ) - contact = models.CharField(max_length=100, - verbose_name=_('Contact'), - blank=True, help_text=_('Point of contact')) + contact = models.CharField( + max_length=100, + verbose_name=_('Contact'), + blank=True, + help_text=_('Point of contact'), + ) - link = InvenTreeURLField(blank=True, verbose_name=_('Link'), help_text=_('Link to external company information')) + link = InvenTreeURLField( + blank=True, + verbose_name=_('Link'), + help_text=_('Link to external company information'), + ) image = StdImageField( upload_to=rename_company_image, null=True, blank=True, - variations={ - 'thumbnail': (128, 128), - 'preview': (256, 256), - }, + variations={'thumbnail': (128, 128), 'preview': (256, 256)}, delete_orphans=True, verbose_name=_('Image'), ) - is_customer = models.BooleanField(default=False, verbose_name=_('is customer'), help_text=_('Do you sell items to this company?')) + is_customer = models.BooleanField( + default=False, + verbose_name=_('is customer'), + help_text=_('Do you sell items to this company?'), + ) - is_supplier = models.BooleanField(default=True, verbose_name=_('is supplier'), help_text=_('Do you purchase items from this company?')) + is_supplier = models.BooleanField( + default=True, + verbose_name=_('is supplier'), + help_text=_('Do you purchase items from this company?'), + ) - is_manufacturer = models.BooleanField(default=False, verbose_name=_('is manufacturer'), help_text=_('Does this company manufacture parts?')) + is_manufacturer = models.BooleanField( + default=False, + verbose_name=_('is manufacturer'), + help_text=_('Does this company manufacture parts?'), + ) currency = models.CharField( max_length=3, @@ -207,13 +236,18 @@ class Company(InvenTreeNotesMixin, MetadataMixin, models.Model): @property def parts(self): """Return SupplierPart objects which are supplied or manufactured by this company.""" - return SupplierPart.objects.filter(Q(supplier=self.id) | Q(manufacturer_part__manufacturer=self.id)).distinct() + return SupplierPart.objects.filter( + Q(supplier=self.id) | Q(manufacturer_part__manufacturer=self.id) + ).distinct() @property def stock_items(self): """Return a list of all stock items supplied or manufactured by this company.""" stock = apps.get_model('stock', 'StockItem') - return stock.objects.filter(Q(supplier_part__supplier=self.id) | Q(supplier_part__manufacturer_part__manufacturer=self.id)).distinct() + return stock.objects.filter( + Q(supplier_part__supplier=self.id) + | Q(supplier_part__manufacturer_part__manufacturer=self.id) + ).distinct() class CompanyAttachment(InvenTreeAttachment): @@ -229,7 +263,8 @@ class CompanyAttachment(InvenTreeAttachment): return os.path.join('company_files', str(self.company.pk)) company = models.ForeignKey( - Company, on_delete=models.CASCADE, + Company, + on_delete=models.CASCADE, verbose_name=_('Company'), related_name='attachments', ) @@ -251,8 +286,9 @@ class Contact(MetadataMixin, models.Model): """Return the API URL associated with the Contcat model""" return reverse('api-contact-list') - company = models.ForeignKey(Company, related_name='contacts', - on_delete=models.CASCADE) + company = models.ForeignKey( + Company, related_name='contacts', on_delete=models.CASCADE + ) name = models.CharField(max_length=100) @@ -281,6 +317,7 @@ class Address(models.Model): class Meta: """Metaclass defines extra model options""" + verbose_name_plural = "Addresses" def __init__(self, *args, **kwargs): @@ -289,13 +326,14 @@ class Address(models.Model): def __str__(self): """Defines string representation of address to supple a one-line to API calls""" - available_lines = [self.line1, - self.line2, - self.postal_code, - self.postal_city, - self.province, - self.country - ] + available_lines = [ + self.line1, + self.line2, + self.postal_code, + self.postal_city, + self.province, + self.country, + ] populated_lines = [] for line in available_lines: @@ -309,7 +347,9 @@ class Address(models.Model): - If this address is marked as "primary", ensure that all other addresses for this company are marked as non-primary """ - others = list(Address.objects.filter(company=self.company).exclude(pk=self.pk).all()) + others = list( + Address.objects.filter(company=self.company).exclude(pk=self.pk).all() + ) # If this is the *only* address for this company, make it the primary one if len(others) == 0: @@ -329,63 +369,88 @@ class Address(models.Model): """Return the API URL associated with the Contcat model""" return reverse('api-address-list') - company = models.ForeignKey(Company, related_name='addresses', - on_delete=models.CASCADE, - verbose_name=_('Company'), - help_text=_('Select company')) + company = models.ForeignKey( + Company, + related_name='addresses', + on_delete=models.CASCADE, + verbose_name=_('Company'), + help_text=_('Select company'), + ) - title = models.CharField(max_length=100, - verbose_name=_('Address title'), - help_text=_('Title describing the address entry'), - blank=False) + title = models.CharField( + max_length=100, + verbose_name=_('Address title'), + help_text=_('Title describing the address entry'), + blank=False, + ) - primary = models.BooleanField(default=False, - verbose_name=_('Primary address'), - help_text=_('Set as primary address')) + primary = models.BooleanField( + default=False, + verbose_name=_('Primary address'), + help_text=_('Set as primary address'), + ) - line1 = models.CharField(max_length=50, - verbose_name=_('Line 1'), - help_text=_('Address line 1'), - blank=True) + line1 = models.CharField( + max_length=50, + verbose_name=_('Line 1'), + help_text=_('Address line 1'), + blank=True, + ) - line2 = models.CharField(max_length=50, - verbose_name=_('Line 2'), - help_text=_('Address line 2'), - blank=True) + line2 = models.CharField( + max_length=50, + verbose_name=_('Line 2'), + help_text=_('Address line 2'), + blank=True, + ) - postal_code = models.CharField(max_length=10, - verbose_name=_('Postal code'), - help_text=_('Postal code'), - blank=True) + postal_code = models.CharField( + max_length=10, + verbose_name=_('Postal code'), + help_text=_('Postal code'), + blank=True, + ) - postal_city = models.CharField(max_length=50, - verbose_name=_('City/Region'), - help_text=_('Postal code city/region'), - blank=True) + postal_city = models.CharField( + max_length=50, + verbose_name=_('City/Region'), + help_text=_('Postal code city/region'), + blank=True, + ) - province = models.CharField(max_length=50, - verbose_name=_('State/Province'), - help_text=_('State or province'), - blank=True) + province = models.CharField( + max_length=50, + verbose_name=_('State/Province'), + help_text=_('State or province'), + blank=True, + ) - country = models.CharField(max_length=50, - verbose_name=_('Country'), - help_text=_('Address country'), - blank=True) + country = models.CharField( + max_length=50, + verbose_name=_('Country'), + help_text=_('Address country'), + blank=True, + ) - shipping_notes = models.CharField(max_length=100, - verbose_name=_('Courier shipping notes'), - help_text=_('Notes for shipping courier'), - blank=True) + shipping_notes = models.CharField( + max_length=100, + verbose_name=_('Courier shipping notes'), + help_text=_('Notes for shipping courier'), + blank=True, + ) - internal_shipping_notes = models.CharField(max_length=100, - verbose_name=_('Internal shipping notes'), - help_text=_('Shipping notes for internal use'), - blank=True) + internal_shipping_notes = models.CharField( + max_length=100, + verbose_name=_('Internal shipping notes'), + help_text=_('Shipping notes for internal use'), + blank=True, + ) - link = InvenTreeURLField(blank=True, - verbose_name=_('Link'), - help_text=_('Link to address information (external)')) + link = InvenTreeURLField( + blank=True, + verbose_name=_('Link'), + help_text=_('Link to address information (external)'), + ) class ManufacturerPart(MetadataMixin, InvenTreeBarcodeMixin, models.Model): @@ -401,6 +466,7 @@ class ManufacturerPart(MetadataMixin, InvenTreeBarcodeMixin, models.Model): class Meta: """Metaclass defines extra model options""" + unique_together = ('part', 'manufacturer', 'MPN') @staticmethod @@ -408,23 +474,21 @@ class ManufacturerPart(MetadataMixin, InvenTreeBarcodeMixin, models.Model): """Return the API URL associated with the ManufacturerPart instance""" return reverse('api-manufacturer-part-list') - part = models.ForeignKey('part.Part', on_delete=models.CASCADE, - related_name='manufacturer_parts', - verbose_name=_('Base Part'), - limit_choices_to={ - 'purchaseable': True, - }, - help_text=_('Select part'), - ) + part = models.ForeignKey( + 'part.Part', + on_delete=models.CASCADE, + related_name='manufacturer_parts', + verbose_name=_('Base Part'), + limit_choices_to={'purchaseable': True}, + help_text=_('Select part'), + ) manufacturer = models.ForeignKey( Company, on_delete=models.CASCADE, null=True, related_name='manufactured_parts', - limit_choices_to={ - 'is_manufacturer': True - }, + limit_choices_to={'is_manufacturer': True}, verbose_name=_('Manufacturer'), help_text=_('Select manufacturer'), ) @@ -433,19 +497,22 @@ class ManufacturerPart(MetadataMixin, InvenTreeBarcodeMixin, models.Model): null=True, max_length=100, verbose_name=_('MPN'), - help_text=_('Manufacturer Part Number') + help_text=_('Manufacturer Part Number'), ) link = InvenTreeURLField( - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Link'), - help_text=_('URL for external manufacturer part link') + help_text=_('URL for external manufacturer part link'), ) description = models.CharField( - max_length=250, blank=True, null=True, + max_length=250, + blank=True, + null=True, verbose_name=_('Description'), - help_text=_('Manufacturer part description') + help_text=_('Manufacturer part description'), ) tags = TaggableManager(blank=True) @@ -456,12 +523,20 @@ class ManufacturerPart(MetadataMixin, InvenTreeBarcodeMixin, models.Model): manufacturer_part = None try: - manufacturer_part = ManufacturerPart.objects.get(part=part, manufacturer=manufacturer, MPN=mpn) + manufacturer_part = ManufacturerPart.objects.get( + part=part, manufacturer=manufacturer, MPN=mpn + ) except ManufacturerPart.DoesNotExist: pass if not manufacturer_part: - manufacturer_part = ManufacturerPart(part=part, manufacturer=manufacturer, MPN=mpn, description=description, link=link) + manufacturer_part = ManufacturerPart( + part=part, + manufacturer=manufacturer, + MPN=mpn, + description=description, + link=link, + ) manufacturer_part.save() return manufacturer_part @@ -491,8 +566,12 @@ class ManufacturerPartAttachment(InvenTreeAttachment): """Return the subdirectory where attachment files for the ManufacturerPart model are located""" return os.path.join("manufacturer_part_files", str(self.manufacturer_part.id)) - manufacturer_part = models.ForeignKey(ManufacturerPart, on_delete=models.CASCADE, - verbose_name=_('Manufacturer Part'), related_name='attachments') + manufacturer_part = models.ForeignKey( + ManufacturerPart, + on_delete=models.CASCADE, + verbose_name=_('Manufacturer Part'), + related_name='attachments', + ) class ManufacturerPartParameter(models.Model): @@ -505,6 +584,7 @@ class ManufacturerPartParameter(models.Model): class Meta: """Metaclass defines extra model options""" + unique_together = ('manufacturer_part', 'name') @staticmethod @@ -523,21 +603,22 @@ class ManufacturerPartParameter(models.Model): max_length=500, blank=False, verbose_name=_('Name'), - help_text=_('Parameter name') + help_text=_('Parameter name'), ) value = models.CharField( max_length=500, blank=False, verbose_name=_('Value'), - help_text=_('Parameter value') + help_text=_('Parameter value'), ) units = models.CharField( max_length=64, - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Units'), - help_text=_('Parameter units') + help_text=_('Parameter units'), ) @@ -551,10 +632,10 @@ class SupplierPartManager(models.Manager): def get_queryset(self): """Prefetch related fields when querying against the SupplierPart model""" # Always prefetch related models - return super().get_queryset().prefetch_related( - 'part', - 'supplier', - 'manufacturer_part__manufacturer', + return ( + super() + .get_queryset() + .prefetch_related('part', 'supplier', 'manufacturer_part__manufacturer') ) @@ -580,6 +661,7 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin class Meta: """Metaclass defines extra model options""" + unique_together = ('part', 'supplier', 'SKU') # This model was moved from the 'Part' app @@ -600,11 +682,7 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin def api_instance_filters(self): """Return custom API filters for this particular instance""" - return { - 'manufacturer_part': { - 'part': self.part.pk - } - } + return {'manufacturer_part': {'part': self.part.pk}} def clean(self): """Custom clean action for the SupplierPart model: @@ -624,14 +702,17 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin try: # Attempt conversion to specified unit native_value = InvenTree.conversion.convert_physical_value( - self.pack_quantity, self.part.units, - strip_units=False + self.pack_quantity, self.part.units, strip_units=False ) # If part units are not provided, value must be dimensionless - if not self.part.units and not InvenTree.conversion.is_dimensionless(native_value): + if not self.part.units and not InvenTree.conversion.is_dimensionless( + native_value + ): raise ValidationError({ - 'pack_quantity': _("Pack units must be compatible with the base part units") + 'pack_quantity': _( + "Pack units must be compatible with the base part units" + ) }) # Native value must be greater than zero @@ -644,16 +725,15 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin self.pack_quantity_native = Decimal(native_value.magnitude) except ValidationError as e: - raise ValidationError({ - 'pack_quantity': e.messages - }) + raise ValidationError({'pack_quantity': e.messages}) # Ensure that the linked manufacturer_part points to the same part! if self.manufacturer_part and self.part: - if self.manufacturer_part.part != self.part: raise ValidationError({ - 'manufacturer_part': _("Linked manufacturer part must reference the same base part"), + 'manufacturer_part': _( + "Linked manufacturer part must reference the same base part" + ) }) def save(self, *args, **kwargs): @@ -666,7 +746,9 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin # Retrieve manufacturer part try: - manufacturer_part = ManufacturerPart.objects.get(manufacturer__name=manufacturer_name, MPN=MPN) + manufacturer_part = ManufacturerPart.objects.get( + manufacturer__name=manufacturer_name, MPN=MPN + ) except (ValueError, Company.DoesNotExist): # ManufacturerPart does not exist pass @@ -676,7 +758,9 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin # Connect ManufacturerPart to SupplierPart self.manufacturer_part = manufacturer_part else: - raise ValidationError(f'SupplierPart {self.__str__} is already linked to {self.manufacturer_part}') + raise ValidationError( + f'SupplierPart {self.__str__} is already linked to {self.manufacturer_part}' + ) self.clean() self.validate_unique() @@ -684,17 +768,17 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin super().save(*args, **kwargs) part = models.ForeignKey( - 'part.Part', on_delete=models.CASCADE, + 'part.Part', + on_delete=models.CASCADE, related_name='supplier_parts', verbose_name=_('Base Part'), - limit_choices_to={ - 'purchaseable': True, - }, + limit_choices_to={'purchaseable': True}, help_text=_('Select part'), ) supplier = models.ForeignKey( - Company, on_delete=models.CASCADE, + Company, + on_delete=models.CASCADE, related_name='supplied_parts', limit_choices_to={'is_supplier': True}, verbose_name=_('Supplier'), @@ -704,49 +788,70 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin SKU = models.CharField( max_length=100, verbose_name=__("SKU = Stock Keeping Unit (supplier part number)", 'SKU'), - help_text=_('Supplier stock keeping unit') + help_text=_('Supplier stock keeping unit'), ) manufacturer_part = models.ForeignKey( - ManufacturerPart, on_delete=models.CASCADE, - blank=True, null=True, + ManufacturerPart, + on_delete=models.CASCADE, + blank=True, + null=True, related_name='supplier_parts', verbose_name=_('Manufacturer Part'), help_text=_('Select manufacturer part'), ) link = InvenTreeURLField( - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Link'), - help_text=_('URL for external supplier part link') + help_text=_('URL for external supplier part link'), ) description = models.CharField( - max_length=250, blank=True, null=True, + max_length=250, + blank=True, + null=True, verbose_name=_('Description'), - help_text=_('Supplier part description') + help_text=_('Supplier part description'), ) note = models.CharField( - max_length=100, blank=True, null=True, + max_length=100, + blank=True, + null=True, verbose_name=_('Note'), - help_text=_('Notes') + help_text=_('Notes'), ) - base_cost = models.DecimalField(max_digits=10, decimal_places=3, default=0, validators=[MinValueValidator(0)], verbose_name=_('base cost'), help_text=_('Minimum charge (e.g. stocking fee)')) + base_cost = models.DecimalField( + max_digits=10, + decimal_places=3, + default=0, + validators=[MinValueValidator(0)], + verbose_name=_('base cost'), + help_text=_('Minimum charge (e.g. stocking fee)'), + ) - packaging = models.CharField(max_length=50, blank=True, null=True, verbose_name=_('Packaging'), help_text=_('Part packaging')) + packaging = models.CharField( + max_length=50, + blank=True, + null=True, + verbose_name=_('Packaging'), + help_text=_('Part packaging'), + ) pack_quantity = models.CharField( max_length=25, verbose_name=_('Pack Quantity'), - help_text=_('Total quantity supplied in a single pack. Leave empty for single items.'), + help_text=_( + 'Total quantity supplied in a single pack. Leave empty for single items.' + ), blank=True, ) pack_quantity_native = RoundingDecimalField( - max_digits=20, decimal_places=10, default=1, - null=True, + max_digits=20, decimal_places=10, default=1, null=True ) def base_quantity(self, quantity=1) -> Decimal: @@ -756,20 +861,29 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin return q - multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], verbose_name=_('multiple'), help_text=_('Order multiple')) + multiple = models.PositiveIntegerField( + default=1, + validators=[MinValueValidator(1)], + verbose_name=_('multiple'), + help_text=_('Order multiple'), + ) # TODO - Reimplement lead-time as a charfield with special validation (pattern matching). # lead_time = models.DurationField(blank=True, null=True) available = models.DecimalField( - max_digits=10, decimal_places=3, default=0, + max_digits=10, + decimal_places=3, + default=0, validators=[MinValueValidator(0)], verbose_name=_('Available'), help_text=_('Quantity available from supplier'), ) availability_updated = models.DateTimeField( - null=True, blank=True, verbose_name=_('Availability Updated'), + null=True, + blank=True, + verbose_name=_('Availability Updated'), help_text=_('Date of last update of availability data'), ) @@ -821,17 +935,15 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin if self.price_breaks.filter(quantity=quantity, part=self.pk).exists(): return - SupplierPriceBreak.objects.create( - part=self, - quantity=quantity, - price=price - ) + SupplierPriceBreak.objects.create(part=self, quantity=quantity, price=price) get_price = common.models.get_price def open_orders(self): """Return a database query for PurchaseOrder line items for this SupplierPart, limited to purchase orders that are open / outstanding.""" - return self.purchase_order_line_items.prefetch_related('order').filter(order__status__in=PurchaseOrderStatusGroups.OPEN) + return self.purchase_order_line_items.prefetch_related('order').filter( + order__status__in=PurchaseOrderStatusGroups.OPEN + ) def on_order(self): """Return the total quantity of items currently on order. @@ -852,7 +964,10 @@ class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin def purchase_orders(self): """Returns a list of purchase orders relating to this supplier part.""" - return [line.order for line in self.purchase_order_line_items.all().prefetch_related('order')] + return [ + line.order + for line in self.purchase_order_line_items.all().prefetch_related('order') + ] @property def pretty_name(self): @@ -891,6 +1006,7 @@ class SupplierPriceBreak(common.models.PriceBreak): class Meta: """Metaclass defines extra model options""" + unique_together = ("part", "quantity") # This model was moved from the 'Part' app @@ -905,22 +1021,31 @@ class SupplierPriceBreak(common.models.PriceBreak): """Return the API URL associated with the SupplierPriceBreak model""" return reverse('api-part-supplier-price-list') - part = models.ForeignKey(SupplierPart, on_delete=models.CASCADE, related_name='pricebreaks', verbose_name=_('Part'),) + part = models.ForeignKey( + SupplierPart, + on_delete=models.CASCADE, + related_name='pricebreaks', + verbose_name=_('Part'), + ) -@receiver(post_save, sender=SupplierPriceBreak, dispatch_uid='post_save_supplier_price_break') +@receiver( + post_save, sender=SupplierPriceBreak, dispatch_uid='post_save_supplier_price_break' +) def after_save_supplier_price(sender, instance, created, **kwargs): """Callback function when a SupplierPriceBreak is created or updated""" if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData(): - if instance.part and instance.part.part: instance.part.part.schedule_pricing_update(create=True) -@receiver(post_delete, sender=SupplierPriceBreak, dispatch_uid='post_delete_supplier_price_break') +@receiver( + post_delete, + sender=SupplierPriceBreak, + dispatch_uid='post_delete_supplier_price_break', +) def after_delete_supplier_price(sender, instance, **kwargs): """Callback function when a SupplierPriceBreak is deleted""" if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData(): - if instance.part and instance.part.part: instance.part.part.schedule_pricing_update(create=False) diff --git a/InvenTree/company/serializers.py b/InvenTree/company/serializers.py index 6d5911749b..42d796affd 100644 --- a/InvenTree/company/serializers.py +++ b/InvenTree/company/serializers.py @@ -10,20 +10,29 @@ from sql_util.utils import SubqueryCount from taggit.serializers import TagListSerializerField import part.filters -from InvenTree.serializers import (InvenTreeAttachmentSerializer, - InvenTreeCurrencySerializer, - InvenTreeDecimalField, - InvenTreeImageSerializerField, - InvenTreeModelSerializer, - InvenTreeMoneySerializer, - InvenTreeTagModelSerializer, - RemoteImageMixin) +from InvenTree.serializers import ( + InvenTreeAttachmentSerializer, + InvenTreeCurrencySerializer, + InvenTreeDecimalField, + InvenTreeImageSerializerField, + InvenTreeModelSerializer, + InvenTreeMoneySerializer, + InvenTreeTagModelSerializer, + RemoteImageMixin, +) from part.serializers import PartBriefSerializer -from .models import (Address, Company, CompanyAttachment, Contact, - ManufacturerPart, ManufacturerPartAttachment, - ManufacturerPartParameter, SupplierPart, - SupplierPriceBreak) +from .models import ( + Address, + Company, + CompanyAttachment, + Contact, + ManufacturerPart, + ManufacturerPartAttachment, + ManufacturerPartParameter, + SupplierPart, + SupplierPriceBreak, +) class CompanyBriefSerializer(InvenTreeModelSerializer): @@ -33,13 +42,7 @@ class CompanyBriefSerializer(InvenTreeModelSerializer): """Metaclass options.""" model = Company - fields = [ - 'pk', - 'url', - 'name', - 'description', - 'image', - ] + fields = ['pk', 'url', 'name', 'description', 'image'] url = serializers.CharField(source='get_absolute_url', read_only=True) @@ -86,7 +89,7 @@ class AddressBriefSerializer(InvenTreeModelSerializer): 'province', 'country', 'shipping_notes', - 'internal_shipping_notes' + 'internal_shipping_notes', ] @@ -119,7 +122,7 @@ class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer): 'parts_manufactured', 'remote_image', 'address_count', - 'primary_address' + 'primary_address', ] @staticmethod @@ -130,13 +133,9 @@ class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer): parts_manufactured=SubqueryCount('manufactured_parts') ) - queryset = queryset.annotate( - parts_supplied=SubqueryCount('supplied_parts') - ) + queryset = queryset.annotate(parts_supplied=SubqueryCount('supplied_parts')) - queryset = queryset.annotate( - address_count=SubqueryCount('addresses') - ) + queryset = queryset.annotate(address_count=SubqueryCount('addresses')) return queryset @@ -150,7 +149,9 @@ class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer): parts_manufactured = serializers.IntegerField(read_only=True) address_count = serializers.IntegerField(read_only=True) - currency = InvenTreeCurrencySerializer(help_text=_('Default currency used for this supplier'), required=True) + currency = InvenTreeCurrencySerializer( + help_text=_('Default currency used for this supplier'), required=True + ) def save(self): """Save the Company instance""" @@ -169,10 +170,7 @@ class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer): # Construct a simplified name for the image filename = f"company_{company.pk}_image.{fmt.lower()}" - company.image.save( - filename, - ContentFile(buffer.getvalue()), - ) + company.image.save(filename, ContentFile(buffer.getvalue())) return self.instance @@ -182,11 +180,10 @@ class CompanyAttachmentSerializer(InvenTreeAttachmentSerializer): class Meta: """Metaclass defines serializer options""" + model = CompanyAttachment - fields = InvenTreeAttachmentSerializer.attachment_fields([ - 'company', - ]) + fields = InvenTreeAttachmentSerializer.attachment_fields(['company']) class ContactSerializer(InvenTreeModelSerializer): @@ -196,14 +193,7 @@ class ContactSerializer(InvenTreeModelSerializer): """Metaclass options""" model = Contact - fields = [ - 'pk', - 'company', - 'name', - 'phone', - 'email', - 'role', - ] + fields = ['pk', 'company', 'name', 'phone', 'email', 'role'] class ManufacturerPartSerializer(InvenTreeTagModelSerializer): @@ -224,7 +214,6 @@ class ManufacturerPartSerializer(InvenTreeTagModelSerializer): 'MPN', 'link', 'barcode_hash', - 'tags', ] @@ -249,11 +238,15 @@ class ManufacturerPartSerializer(InvenTreeTagModelSerializer): part_detail = PartBriefSerializer(source='part', many=False, read_only=True) - manufacturer_detail = CompanyBriefSerializer(source='manufacturer', many=False, read_only=True) + manufacturer_detail = CompanyBriefSerializer( + source='manufacturer', many=False, read_only=True + ) pretty_name = serializers.CharField(read_only=True) - manufacturer = serializers.PrimaryKeyRelatedField(queryset=Company.objects.filter(is_manufacturer=True)) + manufacturer = serializers.PrimaryKeyRelatedField( + queryset=Company.objects.filter(is_manufacturer=True) + ) class ManufacturerPartAttachmentSerializer(InvenTreeAttachmentSerializer): @@ -264,9 +257,7 @@ class ManufacturerPartAttachmentSerializer(InvenTreeAttachmentSerializer): model = ManufacturerPartAttachment - fields = InvenTreeAttachmentSerializer.attachment_fields([ - 'manufacturer_part', - ]) + fields = InvenTreeAttachmentSerializer.attachment_fields(['manufacturer_part']) class ManufacturerPartParameterSerializer(InvenTreeModelSerializer): @@ -295,7 +286,9 @@ class ManufacturerPartParameterSerializer(InvenTreeModelSerializer): if not man_detail: self.fields.pop('manufacturer_part_detail') - manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', many=False, read_only=True) + manufacturer_part_detail = ManufacturerPartSerializer( + source='manufacturer_part', many=False, read_only=True + ) class SupplierPartSerializer(InvenTreeTagModelSerializer): @@ -330,14 +323,10 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer): 'supplier_detail', 'url', 'updated', - 'tags', ] - read_only_fields = [ - 'availability_updated', - 'barcode_hash', - ] + read_only_fields = ['availability_updated', 'barcode_hash'] tags = TagListSerializerField(required=False) @@ -377,19 +366,27 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer): part_detail = PartBriefSerializer(source='part', many=False, read_only=True) - supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True) + supplier_detail = CompanyBriefSerializer( + source='supplier', many=False, read_only=True + ) - manufacturer_detail = CompanyBriefSerializer(source='manufacturer_part.manufacturer', many=False, read_only=True) + manufacturer_detail = CompanyBriefSerializer( + source='manufacturer_part.manufacturer', many=False, read_only=True + ) pretty_name = serializers.CharField(read_only=True) - supplier = serializers.PrimaryKeyRelatedField(queryset=Company.objects.filter(is_supplier=True)) + supplier = serializers.PrimaryKeyRelatedField( + queryset=Company.objects.filter(is_supplier=True) + ) manufacturer = serializers.CharField(read_only=True) MPN = serializers.CharField(read_only=True) - manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', part_detail=False, read_only=True) + manufacturer_part_detail = ManufacturerPartSerializer( + source='manufacturer_part', part_detail=False, read_only=True + ) url = serializers.CharField(source='get_absolute_url', read_only=True) @@ -403,9 +400,7 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer): Fields: in_stock: Current stock quantity for each SupplierPart """ - queryset = queryset.annotate( - in_stock=part.filters.annotate_total_stock() - ) + queryset = queryset.annotate(in_stock=part.filters.annotate_total_stock()) return queryset @@ -436,10 +431,7 @@ class SupplierPartSerializer(InvenTreeTagModelSerializer): MPN = self.initial_data.get('MPN', None) if manufacturer and MPN: - kwargs = { - 'manufacturer': manufacturer, - 'MPN': MPN, - } + kwargs = {'manufacturer': manufacturer, 'MPN': MPN} supplier_part.save(**kwargs) return supplier_part @@ -479,17 +471,19 @@ class SupplierPriceBreakSerializer(InvenTreeModelSerializer): quantity = InvenTreeDecimalField() - price = InvenTreeMoneySerializer( - allow_null=True, - required=True, - label=_('Price'), - ) + price = InvenTreeMoneySerializer(allow_null=True, required=True, label=_('Price')) price_currency = InvenTreeCurrencySerializer() - supplier = serializers.PrimaryKeyRelatedField(source='part.supplier', many=False, read_only=True) + supplier = serializers.PrimaryKeyRelatedField( + source='part.supplier', many=False, read_only=True + ) - supplier_detail = CompanyBriefSerializer(source='part.supplier', many=False, read_only=True) + supplier_detail = CompanyBriefSerializer( + source='part.supplier', many=False, read_only=True + ) # Detail serializer for SupplierPart - part_detail = SupplierPartSerializer(source='part', brief=True, many=False, read_only=True) + part_detail = SupplierPartSerializer( + source='part', brief=True, many=False, read_only=True + ) diff --git a/InvenTree/company/test_api.py b/InvenTree/company/test_api.py index 33720e30c2..934f9e4bb6 100644 --- a/InvenTree/company/test_api.py +++ b/InvenTree/company/test_api.py @@ -12,10 +12,7 @@ from .models import Address, Company, Contact, ManufacturerPart, SupplierPart class CompanyTest(InvenTreeAPITestCase): """Series of tests for the Company DRF API.""" - roles = [ - 'purchase_order.add', - 'purchase_order.change', - ] + roles = ['purchase_order.add', 'purchase_order.change'] @classmethod def setUpTestData(cls): @@ -23,9 +20,18 @@ class CompanyTest(InvenTreeAPITestCase): super().setUpTestData() # Create some company objects to work with - cls.acme = Company.objects.create(name='ACME', description='Supplier', is_customer=False, is_supplier=True) - Company.objects.create(name='Drippy Cup Co.', description='Customer', is_customer=True, is_supplier=False) - Company.objects.create(name='Sippy Cup Emporium', description='Another supplier') + cls.acme = Company.objects.create( + name='ACME', description='Supplier', is_customer=False, is_supplier=True + ) + Company.objects.create( + name='Drippy Cup Co.', + description='Customer', + is_customer=True, + is_supplier=False, + ) + Company.objects.create( + name='Sippy Cup Emporium', description='Another supplier' + ) def test_company_list(self): """Test the list API endpoint for the Company model""" @@ -82,22 +88,13 @@ class CompanyTest(InvenTreeAPITestCase): url = reverse('api-company-list') # Name is required - response = self.post( - url, - { - 'description': 'A description!', - }, - expected_code=400 - ) + response = self.post(url, {'description': 'A description!'}, expected_code=400) # Minimal example, checking default values response = self.post( url, - { - 'name': 'My API Company', - 'description': 'A company created via the API', - }, - expected_code=201 + {'name': 'My API Company', 'description': 'A company created via the API'}, + expected_code=201, ) self.assertTrue(response.data['is_supplier']) @@ -117,7 +114,7 @@ class CompanyTest(InvenTreeAPITestCase): 'is_manufacturer': True, 'is_customer': True, }, - expected_code=201 + expected_code=201, ) self.assertEqual(response.data['currency'], 'AUD') @@ -128,12 +125,8 @@ class CompanyTest(InvenTreeAPITestCase): # Attempt to create with invalid currency response = self.post( url, - { - 'name': "A name", - 'description': 'A description', - 'currency': 'POQD', - }, - expected_code=400 + {'name': "A name", 'description': 'A description', 'currency': 'POQD'}, + expected_code=400, ) self.assertTrue('currency' in response.data) @@ -151,10 +144,8 @@ class ContactTest(InvenTreeAPITestCase): # Create some companies companies = [ - Company( - name=f"Company {idx}", - description="Some company" - ) for idx in range(3) + Company(name=f"Company {idx}", description="Some company") + for idx in range(3) ] Company.objects.bulk_create(companies) @@ -164,10 +155,7 @@ class ContactTest(InvenTreeAPITestCase): # Create some contacts for cmp in Company.objects.all(): contacts += [ - Contact( - company=cmp, - name=f"My name {idx}", - ) for idx in range(3) + Contact(company=cmp, name=f"My name {idx}") for idx in range(3) ] Contact.objects.bulk_create(contacts) @@ -187,13 +175,7 @@ class ContactTest(InvenTreeAPITestCase): # Filter by particular company for cmp in Company.objects.all(): - response = self.get( - self.url, - { - 'company': cmp.pk, - }, - expected_code=200 - ) + response = self.get(self.url, {'company': cmp.pk}, expected_code=200) self.assertEqual(len(response.data), 3) @@ -205,23 +187,13 @@ class ContactTest(InvenTreeAPITestCase): # Without required permissions, creation should fail self.post( - self.url, - { - 'company': company.pk, - 'name': 'Joe Bloggs', - }, - expected_code=403 + self.url, {'company': company.pk, 'name': 'Joe Bloggs'}, expected_code=403 ) self.assignRole('return_order.add') self.post( - self.url, - { - 'company': company.pk, - 'name': 'Joe Bloggs', - }, - expected_code=201 + self.url, {'company': company.pk, 'name': 'Joe Bloggs'}, expected_code=201 ) self.assertEqual(Contact.objects.count(), n + 1) @@ -239,23 +211,11 @@ class ContactTest(InvenTreeAPITestCase): for key in ['pk', 'name', 'role']: self.assertIn(key, data) - self.patch( - url, - { - 'role': 'model', - }, - expected_code=403 - ) + self.patch(url, {'role': 'model'}, expected_code=403) self.assignRole('purchase_order.change') - self.patch( - url, - { - 'role': 'x', - }, - expected_code=200 - ) + self.patch(url, {'role': 'x'}, expected_code=200) # Get the contact again contact = Contact.objects.first() @@ -291,10 +251,8 @@ class AddressTest(InvenTreeAPITestCase): cls.num_addr = 3 # Create some companies companies = [ - Company( - name=f"Company {idx}", - description="Some company" - ) for idx in range(cls.num_companies) + Company(name=f"Company {idx}", description="Some company") + for idx in range(cls.num_companies) ] Company.objects.bulk_create(companies) @@ -304,10 +262,8 @@ class AddressTest(InvenTreeAPITestCase): # Create some contacts for cmp in Company.objects.all(): addresses += [ - Address( - company=cmp, - title=f"Address no. {idx}", - ) for idx in range(cls.num_addr) + Address(company=cmp, title=f"Address no. {idx}") + for idx in range(cls.num_addr) ] cls.url = reverse('api-address-list') @@ -332,21 +288,11 @@ class AddressTest(InvenTreeAPITestCase): """Test creating a new address""" company = Company.objects.first() - self.post(self.url, - { - 'company': company.pk, - 'title': 'HQ' - }, - expected_code=403) + self.post(self.url, {'company': company.pk, 'title': 'HQ'}, expected_code=403) self.assignRole('purchase_order.add') - self.post(self.url, - { - 'company': company.pk, - 'title': 'HQ' - }, - expected_code=201) + self.post(self.url, {'company': company.pk, 'title': 'HQ'}, expected_code=201) def test_get(self): """Test that objects are properly returned from a get""" @@ -357,7 +303,15 @@ class AddressTest(InvenTreeAPITestCase): self.assertEqual(response.data['pk'], addr.pk) - for key in ['title', 'line1', 'line2', 'postal_code', 'postal_city', 'province', 'country']: + for key in [ + 'title', + 'line1', + 'line2', + 'postal_code', + 'postal_city', + 'province', + 'country', + ]: self.assertIn(key, response.data) def test_edit(self): @@ -366,23 +320,11 @@ class AddressTest(InvenTreeAPITestCase): url = reverse('api-address-detail', kwargs={'pk': addr.pk}) - self.patch( - url, - { - 'title': 'Hello' - }, - expected_code=403 - ) + self.patch(url, {'title': 'Hello'}, expected_code=403) self.assignRole('purchase_order.change') - self.patch( - url, - { - 'title': 'World' - }, - expected_code=200 - ) + self.patch(url, {'title': 'World'}, expected_code=200) data = self.get(url, expected_code=200).data @@ -415,10 +357,7 @@ class ManufacturerTest(InvenTreeAPITestCase): 'supplier_part', ] - roles = [ - 'part.add', - 'part.change', - ] + roles = ['part.add', 'part.change'] def test_manufacturer_part_list(self): """Test the ManufacturerPart API list functionality""" @@ -429,11 +368,7 @@ class ManufacturerTest(InvenTreeAPITestCase): self.assertEqual(len(response.data), 3) # Create manufacturer part - data = { - 'part': 1, - 'manufacturer': 7, - 'MPN': 'MPN_TEST', - } + data = {'part': 1, 'manufacturer': 7, 'MPN': 'MPN_TEST'} response = self.client.post(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.data['MPN'], 'MPN_TEST') @@ -456,9 +391,7 @@ class ManufacturerTest(InvenTreeAPITestCase): self.assertEqual(response.data['MPN'], 'MPN123') # Change the MPN - data = { - 'MPN': 'MPN-TEST-123', - } + data = {'MPN': 'MPN-TEST-123'} response = self.client.patch(url, data, format='json') @@ -485,7 +418,7 @@ class ManufacturerTest(InvenTreeAPITestCase): 'MPN': 'PART_NUMBER', 'link': 'https://www.axel-larsson.se/Exego.aspx?p_id=341&ArtNr=0804020E', }, - expected_code=201 + expected_code=201, ) pk = response.data['pk'] @@ -503,10 +436,16 @@ class ManufacturerTest(InvenTreeAPITestCase): self.assertEqual(response.status_code, status.HTTP_201_CREATED) # Check link is not modified - self.assertEqual(response.data['link'], 'https://www.axel-larsson.se/Exego.aspx?p_id=341&ArtNr=0804020E') + self.assertEqual( + response.data['link'], + 'https://www.axel-larsson.se/Exego.aspx?p_id=341&ArtNr=0804020E', + ) # Check link is not modified - self.assertEqual(response.data['link'], 'https://www.axel-larsson.se/Exego.aspx?p_id=341&ArtNr=0804020E') + self.assertEqual( + response.data['link'], + 'https://www.axel-larsson.se/Exego.aspx?p_id=341&ArtNr=0804020E', + ) class SupplierPartTest(InvenTreeAPITestCase): @@ -521,12 +460,7 @@ class SupplierPartTest(InvenTreeAPITestCase): 'supplier_part', ] - roles = [ - 'part.add', - 'part.change', - 'part.add', - 'purchase_order.change', - ] + roles = ['part.add', 'part.change', 'part.add', 'purchase_order.change'] def test_supplier_part_list(self): """Test the SupplierPart API list functionality""" @@ -543,10 +477,7 @@ class SupplierPartTest(InvenTreeAPITestCase): self.assertEqual(len(response.data), supplier.supplied_parts.count()) # Filter by Part reference - expected = { - 1: 4, - 25: 2, - } + expected = {1: 4, 25: 2} for pk, n in expected.items(): response = self.get(url, {'part': pk}, expected_code=200) @@ -559,12 +490,7 @@ class SupplierPartTest(InvenTreeAPITestCase): # Should fail when sending an invalid 'available' field response = self.post( url, - { - 'part': 1, - 'supplier': 2, - 'SKU': 'QQ', - 'available': 'not a number', - }, + {'part': 1, 'supplier': 2, 'SKU': 'QQ', 'available': 'not a number'}, expected_code=400, ) @@ -572,13 +498,7 @@ class SupplierPartTest(InvenTreeAPITestCase): # Create a SupplierPart without specifying available quantity response = self.post( - url, - { - 'part': 1, - 'supplier': 2, - 'SKU': 'QQ', - }, - expected_code=201 + url, {'part': 1, 'supplier': 2, 'SKU': 'QQ'}, expected_code=201 ) sp = SupplierPart.objects.get(pk=response.data['pk']) @@ -589,9 +509,7 @@ class SupplierPartTest(InvenTreeAPITestCase): # Now, *update* the available quantity via the API self.patch( reverse('api-supplier-part-detail', kwargs={'pk': sp.pk}), - { - 'available': 1234, - }, + {'available': 1234}, expected_code=200, ) @@ -602,12 +520,7 @@ class SupplierPartTest(InvenTreeAPITestCase): # We should also be able to create a SupplierPart with initial 'available' quantity response = self.post( url, - { - 'part': 1, - 'supplier': 2, - 'SKU': 'QQQ', - 'available': 999, - }, + {'part': 1, 'supplier': 2, 'SKU': 'QQQ', 'available': 999}, expected_code=201, ) @@ -629,11 +542,7 @@ class CompanyMetadataAPITest(InvenTreeAPITestCase): 'supplier_part', ] - roles = [ - 'company.change', - 'purchase_order.change', - 'part.change', - ] + roles = ['company.change', 'purchase_order.change', 'part.change'] def metatester(self, apikey, model): """Generic tester""" @@ -651,17 +560,15 @@ class CompanyMetadataAPITest(InvenTreeAPITestCase): self.patch( url, - { - 'metadata': { - f'abc-{numstr}': f'xyz-{apikey}-{numstr}', - } - }, - expected_code=200 + {'metadata': {f'abc-{numstr}': f'xyz-{apikey}-{numstr}'}}, + expected_code=200, ) # Refresh modeldata.refresh_from_db() - self.assertEqual(modeldata.get_metadata(f'abc-{numstr}'), f'xyz-{apikey}-{numstr}') + self.assertEqual( + modeldata.get_metadata(f'abc-{numstr}'), f'xyz-{apikey}-{numstr}' + ) def test_metadata(self): """Test all endpoints""" diff --git a/InvenTree/company/test_migrations.py b/InvenTree/company/test_migrations.py index d8285ee5a1..22cbd4d0e6 100644 --- a/InvenTree/company/test_migrations.py +++ b/InvenTree/company/test_migrations.py @@ -16,9 +16,7 @@ class TestForwardMigrations(MigratorTestCase): Company = self.old_state.apps.get_model('company', 'company') Company.objects.create( - name='MSPC', - description='Michael Scotts Paper Company', - is_supplier=True + name='MSPC', description='Michael Scotts Paper Company', is_supplier=True ) def test_migrations(self): @@ -49,7 +47,10 @@ class TestManufacturerField(MigratorTestCase): part = Part.objects.create( name='Screw', description='A single screw', - level=0, tree_id=0, lft=0, rght=0 + level=0, + tree_id=0, + lft=0, + rght=0, ) # Create a company to act as the supplier @@ -62,17 +63,11 @@ class TestManufacturerField(MigratorTestCase): # Add some SupplierPart objects SupplierPart.objects.create( - part=part, - supplier=supplier, - SKU='SCREW.001', - manufacturer_name='ACME', + part=part, supplier=supplier, SKU='SCREW.001', manufacturer_name='ACME' ) SupplierPart.objects.create( - part=part, - supplier=supplier, - SKU='SCREW.002', - manufacturer_name='Zero Corp', + part=part, supplier=supplier, SKU='SCREW.002', manufacturer_name='Zero Corp' ) self.assertEqual(Company.objects.count(), 1) @@ -186,17 +181,12 @@ class TestManufacturerPart(MigratorTestCase): # No Manufacturer SupplierPart.objects.create( - part=part, - supplier=supplier_2, - SKU='CAP-CER-01UF-2', - MPN='MUR-CAP-123456', + part=part, supplier=supplier_2, SKU='CAP-CER-01UF-2', MPN='MUR-CAP-123456' ) # No Manufacturer data SupplierPart.objects.create( - part=part, - supplier=supplier_2, - SKU='CAP-CER-01UF-3', + part=part, supplier=supplier_2, SKU='CAP-CER-01UF-3' ) def test_manufacturer_part_objects(self): @@ -238,17 +228,20 @@ class TestCurrencyMigration(MigratorTestCase): Part = self.old_state.apps.get_model('part', 'part') part = Part.objects.create( - name="PART", description="A purchaseable part", + name="PART", + description="A purchaseable part", purchaseable=True, level=0, tree_id=0, lft=0, - rght=0 + rght=0, ) Company = self.old_state.apps.get_model('company', 'company') - supplier = Company.objects.create(name='Supplier', description='A supplier', is_supplier=True) + supplier = Company.objects.create( + name='Supplier', description='A supplier', is_supplier=True + ) SupplierPart = self.old_state.apps.get_model('company', 'supplierpart') @@ -256,8 +249,12 @@ class TestCurrencyMigration(MigratorTestCase): Currency = self.old_state.apps.get_model('common', 'currency') - aud = Currency.objects.create(symbol='$', suffix='AUD', description='Australian Dollars', value=1.0) - usd = Currency.objects.create(symbol='$', suffix='USD', description='US Dollars', value=1.0) + aud = Currency.objects.create( + symbol='$', suffix='AUD', description='Australian Dollars', value=1.0 + ) + usd = Currency.objects.create( + symbol='$', suffix='USD', description='US Dollars', value=1.0 + ) PB = self.old_state.apps.get_model('company', 'supplierpricebreak') @@ -332,12 +329,18 @@ class TestSupplierPartQuantity(MigratorTestCase): SupplierPart = self.old_state.apps.get_model('company', 'supplierpart') self.part = Part.objects.create( - name="PART", description="A purchaseable part", + name="PART", + description="A purchaseable part", purchaseable=True, - level=0, tree_id=0, lft=0, rght=0 + level=0, + tree_id=0, + lft=0, + rght=0, ) - self.supplier = Company.objects.create(name='Supplier', description='A supplier', is_supplier=True) + self.supplier = Company.objects.create( + name='Supplier', description='A supplier', is_supplier=True + ) self.supplier_parts = [] @@ -356,7 +359,6 @@ class TestSupplierPartQuantity(MigratorTestCase): SupplierPart = self.new_state.apps.get_model('company', 'supplierpart') for i, sp in enumerate(SupplierPart.objects.all()): - self.assertEqual(sp.pack_quantity, str(i + 1)) self.assertEqual(sp.pack_quantity_native, i + 1) diff --git a/InvenTree/company/test_supplier_parts.py b/InvenTree/company/test_supplier_parts.py index 21c34d9bbf..e00c78d68a 100644 --- a/InvenTree/company/test_supplier_parts.py +++ b/InvenTree/company/test_supplier_parts.py @@ -15,17 +15,15 @@ class SupplierPartPackUnitsTests(InvenTreeTestCase): def test_pack_quantity_dimensionless(self): """Test valid values for the 'pack_quantity' field""" # Create a part without units (dimensionless) - part = Part.objects.create(name='Test Part', description='Test part description', component=True) + part = Part.objects.create( + name='Test Part', description='Test part description', component=True + ) # Create a supplier (company) company = Company.objects.create(name='Test Company', is_supplier=True) # Create a supplier part for this part - sp = SupplierPart.objects.create( - part=part, - supplier=company, - SKU='TEST-SKU' - ) + sp = SupplierPart.objects.create(part=part, supplier=company, SKU='TEST-SKU') # All these values are valid for a dimensionless part pass_tests = { @@ -37,14 +35,7 @@ class SupplierPartPackUnitsTests(InvenTreeTestCase): } # All these values are invalid for a dimensionless part - fail_tests = [ - '1.2m', - '-1', - '0', - '0.0', - '100 feet', - '0 amps' - ] + fail_tests = ['1.2m', '-1', '0', '0.0', '100 feet', '0 amps'] for test, expected in pass_tests.items(): sp.pack_quantity = test @@ -59,17 +50,18 @@ class SupplierPartPackUnitsTests(InvenTreeTestCase): def test_pack_quantity(self): """Test pack_quantity for a part with a specified dimension""" # Create a part with units 'm' - part = Part.objects.create(name='Test Part', description='Test part description', component=True, units='m') + part = Part.objects.create( + name='Test Part', + description='Test part description', + component=True, + units='m', + ) # Create a supplier (company) company = Company.objects.create(name='Test Company', is_supplier=True) # Create a supplier part for this part - sp = SupplierPart.objects.create( - part=part, - supplier=company, - SKU='TEST-SKU' - ) + sp = SupplierPart.objects.create(part=part, supplier=company, SKU='TEST-SKU') # All these values are valid for a part with dimension 'm' pass_tests = { @@ -87,23 +79,14 @@ class SupplierPartPackUnitsTests(InvenTreeTestCase): # All these values are invalid for a part with dimension 'm' # Either the values are invalid, or the units are incompatible - fail_tests = [ - '-1', - '-1m', - '0', - '0m', - '12 deg', - '57 amps', - '-12 oz', - '17 yaks', - ] + fail_tests = ['-1', '-1m', '0', '0m', '12 deg', '57 amps', '-12 oz', '17 yaks'] for test, expected in pass_tests.items(): sp.pack_quantity = test sp.full_clean() self.assertEqual( round(Decimal(sp.pack_quantity_native), 10), - round(Decimal(str(expected)), 10) + round(Decimal(str(expected)), 10), ) for test in fail_tests: diff --git a/InvenTree/company/test_views.py b/InvenTree/company/test_views.py index 274162ccea..307cb8d6c8 100644 --- a/InvenTree/company/test_views.py +++ b/InvenTree/company/test_views.py @@ -36,7 +36,9 @@ class CompanyViewTest(InvenTreeTestCase): def test_manufacturer_part_detail_view(self): """Test the manufacturer part detail view.""" - response = self.client.get(reverse('manufacturer-part-detail', kwargs={'pk': 1})) + response = self.client.get( + reverse('manufacturer-part-detail', kwargs={'pk': 1}) + ) self.assertEqual(response.status_code, 200) self.assertContains(response, 'MPN123') diff --git a/InvenTree/company/tests.py b/InvenTree/company/tests.py index 1f6e7ab56d..cbb235c2a4 100644 --- a/InvenTree/company/tests.py +++ b/InvenTree/company/tests.py @@ -8,8 +8,14 @@ from django.test import TestCase from part.models import Part -from .models import (Address, Company, Contact, ManufacturerPart, SupplierPart, - rename_company_image) +from .models import ( + Address, + Company, + Contact, + ManufacturerPart, + SupplierPart, + rename_company_image, +) class CompanySimpleTest(TestCase): @@ -31,11 +37,13 @@ class CompanySimpleTest(TestCase): """Perform initialization for the tests in this class""" super().setUpTestData() - Company.objects.create(name='ABC Co.', - description='Seller of ABC products', - website='www.abc-sales.com', - is_customer=False, - is_supplier=True) + Company.objects.create( + name='ABC Co.', + description='Seller of ABC products', + website='www.abc-sales.com', + is_customer=False, + is_supplier=True, + ) cls.acme0001 = SupplierPart.objects.get(SKU='ACME0001') cls.acme0002 = SupplierPart.objects.get(SKU='ACME0002') @@ -113,18 +121,14 @@ class CompanySimpleTest(TestCase): """Test validation for currency selection.""" # Create a company with a valid currency code (should pass) company = Company.objects.create( - name='Test', - description='Toast', - currency='AUD', + name='Test', description='Toast', currency='AUD' ) company.full_clean() # Create a company with an invalid currency code (should fail) company = Company.objects.create( - name='test', - description='Toasty', - currency='XZY', + name='test', description='Toasty', currency='XZY' ) with self.assertRaises(ValidationError): @@ -154,7 +158,9 @@ class ContactSimpleTest(TestCase): def setUp(self): """Initialization for the tests in this class""" # Create a simple company - self.c = Company.objects.create(name='Test Corp.', description='We make stuff good') + self.c = Company.objects.create( + name='Test Corp.', description='We make stuff good' + ) # Add some contacts Contact.objects.create(name='Joe Smith', company=self.c) @@ -178,7 +184,9 @@ class AddressTest(TestCase): def setUp(self): """Initialization for the tests in this class""" # Create a simple company - self.c = Company.objects.create(name='Test Corp.', description='We make stuff good') + self.c = Company.objects.create( + name='Test Corp.', description='We make stuff good' + ) def test_create(self): """Test that object creation with only company supplied is successful""" @@ -221,20 +229,21 @@ class AddressTest(TestCase): pct = "City" pv = "Province" cn = "COUNTRY" - addr = Address.objects.create(company=self.c, - title=t, - line1=l1, - line2=l2, - postal_code=pcd, - postal_city=pct, - province=pv, - country=cn) + addr = Address.objects.create( + company=self.c, + title=t, + line1=l1, + line2=l2, + postal_code=pcd, + postal_city=pct, + province=pv, + country=cn, + ) self.assertEqual(str(addr), f'{l1}, {l2}, {pcd}, {pct}, {pv}, {cn}') - addr2 = Address.objects.create(company=self.c, - title=t, - line1=l1, - postal_code=pcd) + addr2 = Address.objects.create( + company=self.c, title=t, line1=l1, postal_code=pcd + ) self.assertEqual(str(addr2), f'{l1}, {pcd}') @@ -242,13 +251,7 @@ class AddressTest(TestCase): class ManufacturerPartSimpleTest(TestCase): """Unit tests for the ManufacturerPart model""" - fixtures = [ - 'category', - 'company', - 'location', - 'part', - 'manufacturer_part', - ] + fixtures = ['category', 'company', 'location', 'part', 'manufacturer_part'] def setUp(self): """Initialization for the unit tests in this class""" @@ -266,9 +269,7 @@ class ManufacturerPartSimpleTest(TestCase): # Create a supplier part supplier = Company.objects.get(pk=5) supplier_part = SupplierPart.objects.create( - part=self.part, - supplier=supplier, - SKU='SKU_TEST', + part=self.part, supplier=supplier, SKU='SKU_TEST' ) supplier_part.save() diff --git a/InvenTree/company/urls.py b/InvenTree/company/urls.py index 71985964fb..bddc745a72 100644 --- a/InvenTree/company/urls.py +++ b/InvenTree/company/urls.py @@ -5,29 +5,41 @@ from django.urls import include, path, re_path from . import views company_urls = [ - # Detail URLs for a specific Company instance - path(r'/', include([ - re_path(r'^.*$', views.CompanyDetail.as_view(), name='company-detail'), - ])), - + path( + r'/', + include([ + re_path(r'^.*$', views.CompanyDetail.as_view(), name='company-detail') + ]), + ), re_path(r'suppliers/', views.CompanyIndex.as_view(), name='supplier-index'), re_path(r'manufacturers/', views.CompanyIndex.as_view(), name='manufacturer-index'), re_path(r'customers/', views.CompanyIndex.as_view(), name='customer-index'), - # Redirect any other patterns to the 'company' index which displays all companies re_path(r'^.*$', views.CompanyIndex.as_view(), name='company-index'), ] manufacturer_part_urls = [ - - path(r'/', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part.html'), name='manufacturer-part-detail'), + path( + r'/', + views.ManufacturerPartDetail.as_view( + template_name='company/manufacturer_part.html' + ), + name='manufacturer-part-detail', + ) ] supplier_part_urls = [ - path(r'/', include([ - re_path('^.*$', views.SupplierPartDetail.as_view(template_name='company/supplier_part.html'), name='supplier-part-detail'), - ])) - - + path( + r'/', + include([ + re_path( + '^.*$', + views.SupplierPartDetail.as_view( + template_name='company/supplier_part.html' + ), + name='supplier-part-detail', + ) + ]), + ) ] diff --git a/InvenTree/company/views.py b/InvenTree/company/views.py index c8b2b31dc0..a8c85ef7f8 100644 --- a/InvenTree/company/views.py +++ b/InvenTree/company/views.py @@ -44,14 +44,14 @@ class CompanyIndex(InvenTreeRoleMixin, ListView): 'button_text': _('New Customer'), 'filters': {'is_customer': 'true'}, 'pagetype': 'customers', - } + }, } default = { 'title': _('Companies'), 'button_text': _('New Company'), 'filters': {}, - 'pagetype': 'companies' + 'pagetype': 'companies', } context = None @@ -88,6 +88,7 @@ class CompanyIndex(InvenTreeRoleMixin, ListView): class CompanyDetail(InvenTreePluginViewMixin, DetailView): """Detail view for Company object.""" + context_obect_name = 'company' template_name = 'company/detail.html' queryset = Company.objects.all() @@ -97,6 +98,7 @@ class CompanyDetail(InvenTreePluginViewMixin, DetailView): class ManufacturerPartDetail(InvenTreePluginViewMixin, DetailView): """Detail view for ManufacturerPart.""" + model = ManufacturerPart template_name = 'company/manufacturer_part_detail.html' context_object_name = 'part' @@ -106,6 +108,7 @@ class ManufacturerPartDetail(InvenTreePluginViewMixin, DetailView): class SupplierPartDetail(InvenTreePluginViewMixin, DetailView): """Detail view for SupplierPart.""" + model = SupplierPart template_name = 'company/supplier_part_detail.html' context_object_name = 'part' diff --git a/InvenTree/generic/states/__init__.py b/InvenTree/generic/states/__init__.py index 772cec0ec0..8d6f1e870e 100644 --- a/InvenTree/generic/states/__init__.py +++ b/InvenTree/generic/states/__init__.py @@ -9,9 +9,4 @@ States can be extended with custom options for each InvenTree instance - those o from .states import StatusCode from .transition import StateTransitionMixin, TransitionMethod, storage -__all__ = [ - StatusCode, - storage, - TransitionMethod, - StateTransitionMixin, -] +__all__ = [StatusCode, storage, TransitionMethod, StateTransitionMixin] diff --git a/InvenTree/generic/states/api.py b/InvenTree/generic/states/api.py index e5c07d5128..c5cee43611 100644 --- a/InvenTree/generic/states/api.py +++ b/InvenTree/generic/states/api.py @@ -18,9 +18,7 @@ class StatusView(APIView): all available 'StockStatus' codes """ - permission_classes = [ - permissions.IsAuthenticated, - ] + permission_classes = [permissions.IsAuthenticated] # Override status_class for implementing subclass MODEL_REF = 'statusmodel' @@ -30,7 +28,9 @@ class StatusView(APIView): status_model = self.kwargs.get(self.MODEL_REF, None) if status_model is None: - raise ValidationError(f"StatusView view called without '{self.MODEL_REF}' parameter") + raise ValidationError( + f"StatusView view called without '{self.MODEL_REF}' parameter" + ) return status_model @@ -44,10 +44,7 @@ class StatusView(APIView): if not issubclass(status_class, StatusCode): raise NotImplementedError("`status_class` not a valid StatusCode class") - data = { - 'class': status_class.__name__, - 'values': status_class.dict(), - } + data = {'class': status_class.__name__, 'values': status_class.dict()} return Response(data) @@ -55,9 +52,7 @@ class StatusView(APIView): class AllStatusViews(StatusView): """Endpoint for listing all defined status models.""" - permission_classes = [ - permissions.IsAuthenticated, - ] + permission_classes = [permissions.IsAuthenticated] def get(self, request, *args, **kwargs): """Perform a GET request to learn information about status codes""" diff --git a/InvenTree/generic/states/states.py b/InvenTree/generic/states/states.py index 7d23ca359d..6638b3cc05 100644 --- a/InvenTree/generic/states/states.py +++ b/InvenTree/generic/states/states.py @@ -1,4 +1,5 @@ """Generic implementation of status for InvenTree models.""" + import enum import re @@ -148,12 +149,10 @@ class StatusCode(BaseEnum): @classmethod def dict(cls, key=None): """Return a dict representation containing all required information""" - return {x.name: { - 'color': x.color, - 'key': x.value, - 'label': x.label, - 'name': x.name, - } for x in cls.values(key)} + return { + x.name: {'color': x.color, 'key': x.value, 'label': x.label, 'name': x.name} + for x in cls.values(key) + } @classmethod def list(cls): diff --git a/InvenTree/generic/states/test_transition.py b/InvenTree/generic/states/test_transition.py index 7376b69ae9..a18314519d 100644 --- a/InvenTree/generic/states/test_transition.py +++ b/InvenTree/generic/states/test_transition.py @@ -35,8 +35,7 @@ class TransitionTests(InvenTreeTestCase): def test_class(self): """Ensure that the class itself works.""" - class ErrorImplementation(TransitionMethod): - ... + class ErrorImplementation(TransitionMethod): ... with self.assertRaises(NotImplementedError): ErrorImplementation() @@ -90,7 +89,6 @@ class TransitionTests(InvenTreeTestCase): class ValidImplementation(TransitionMethod): def transition(self, *args, **kwargs): - global raise_function if raise_function: @@ -103,7 +101,9 @@ class TransitionTests(InvenTreeTestCase): self.assertIn(ValidImplementation, storage.list) # Ensure that the function is called - self.assertEqual(StateTransitionMixin.handle_transition(0, 1, self, self, dflt), 1234) + self.assertEqual( + StateTransitionMixin.handle_transition(0, 1, self, self, dflt), 1234 + ) _clean_storage([ValidImplementationNoEffect, ValidImplementation]) diff --git a/InvenTree/generic/states/tests.py b/InvenTree/generic/states/tests.py index 81bc84732d..d20537ecfd 100644 --- a/InvenTree/generic/states/tests.py +++ b/InvenTree/generic/states/tests.py @@ -1,4 +1,5 @@ """Tests for the generic states module.""" + from django.test.client import RequestFactory from django.utils.translation import gettext_lazy as _ @@ -27,6 +28,7 @@ class GeneralStatus(StatusCode): class GeneralStateTest(InvenTreeTestCase): """Test that the StatusCode class works.""" + def test_code_definition(self): """Test that the status code class has been defined correctly.""" self.assertEqual(GeneralStatus.PENDING, 10) @@ -36,36 +38,84 @@ class GeneralStateTest(InvenTreeTestCase): def test_code_functions(self): """Test that the status code class functions work correctly""" # render - self.assertEqual(GeneralStatus.render(10), "Pending") - self.assertEqual(GeneralStatus.render(20), "Placed") + self.assertEqual( + GeneralStatus.render(10), + "Pending", + ) + self.assertEqual( + GeneralStatus.render(20), + "Placed", + ) # render with invalid key self.assertEqual(GeneralStatus.render(100), 100) # list - self.assertEqual(GeneralStatus.list(), [{'color': 'secondary', 'key': 10, 'label': 'Pending', 'name': 'PENDING'}, {'color': 'primary', 'key': 20, 'label': 'Placed', 'name': 'PLACED'}, {'color': 'success', 'key': 30, 'label': 'Complete', 'name': 'COMPLETE'}]) + self.assertEqual( + GeneralStatus.list(), + [ + { + 'color': 'secondary', + 'key': 10, + 'label': 'Pending', + 'name': 'PENDING', + }, + {'color': 'primary', 'key': 20, 'label': 'Placed', 'name': 'PLACED'}, + { + 'color': 'success', + 'key': 30, + 'label': 'Complete', + 'name': 'COMPLETE', + }, + ], + ) # text self.assertEqual(GeneralStatus.text(10), 'Pending') self.assertEqual(GeneralStatus.text(20), 'Placed') # items - self.assertEqual(list(GeneralStatus.items()), [(10, 'Pending'), (20, 'Placed'), (30, 'Complete')]) + self.assertEqual( + list(GeneralStatus.items()), + [(10, 'Pending'), (20, 'Placed'), (30, 'Complete')], + ) # keys self.assertEqual(list(GeneralStatus.keys()), ([10, 20, 30])) # labels - self.assertEqual(list(GeneralStatus.labels()), ['Pending', 'Placed', 'Complete']) + self.assertEqual( + list(GeneralStatus.labels()), ['Pending', 'Placed', 'Complete'] + ) # names - self.assertEqual(GeneralStatus.names(), {'PENDING': 10, 'PLACED': 20, 'COMPLETE': 30}) + self.assertEqual( + GeneralStatus.names(), {'PENDING': 10, 'PLACED': 20, 'COMPLETE': 30} + ) # dict - self.assertEqual(GeneralStatus.dict(), { - 'PENDING': {'key': 10, 'name': 'PENDING', 'label': 'Pending', 'color': 'secondary'}, - 'PLACED': {'key': 20, 'name': 'PLACED', 'label': 'Placed', 'color': 'primary'}, - 'COMPLETE': {'key': 30, 'name': 'COMPLETE', 'label': 'Complete', 'color': 'success'}, - }) + self.assertEqual( + GeneralStatus.dict(), + { + 'PENDING': { + 'key': 10, + 'name': 'PENDING', + 'label': 'Pending', + 'color': 'secondary', + }, + 'PLACED': { + 'key': 20, + 'name': 'PLACED', + 'label': 'Placed', + 'color': 'primary', + }, + 'COMPLETE': { + 'key': 30, + 'name': 'COMPLETE', + 'label': 'Complete', + 'color': 'success', + }, + }, + ) # label self.assertEqual(GeneralStatus.label(10), 'Pending') @@ -74,7 +124,10 @@ class GeneralStateTest(InvenTreeTestCase): """Test that the status code tag functions.""" from .tags import status_label - self.assertEqual(status_label('general', 10), "Pending") + self.assertEqual( + status_label('general', 10), + "Pending", + ) # invalid type with self.assertRaises(ValueError) as e: @@ -87,17 +140,45 @@ class GeneralStateTest(InvenTreeTestCase): def test_api(self): """Test StatusView API view.""" view = StatusView.as_view() - rqst = RequestFactory().get('status/',) + rqst = RequestFactory().get('status/') force_authenticate(rqst, user=self.user) # Correct call resp = view(rqst, **{StatusView.MODEL_REF: GeneralStatus}) - self.assertEqual(resp.data, {'class': 'GeneralStatus', 'values': {'COMPLETE': {'key': 30, 'name': 'COMPLETE', 'label': 'Complete', 'color': 'success'}, 'PENDING': {'key': 10, 'name': 'PENDING', 'label': 'Pending', 'color': 'secondary'}, 'PLACED': {'key': 20, 'name': 'PLACED', 'label': 'Placed', 'color': 'primary'}}}) + self.assertEqual( + resp.data, + { + 'class': 'GeneralStatus', + 'values': { + 'COMPLETE': { + 'key': 30, + 'name': 'COMPLETE', + 'label': 'Complete', + 'color': 'success', + }, + 'PENDING': { + 'key': 10, + 'name': 'PENDING', + 'label': 'Pending', + 'color': 'secondary', + }, + 'PLACED': { + 'key': 20, + 'name': 'PLACED', + 'label': 'Placed', + 'color': 'primary', + }, + }, + }, + ) # No status defined resp = view(rqst, **{StatusView.MODEL_REF: None}) self.assertEqual(resp.status_code, 400) - self.assertEqual(str(resp.rendered_content, 'utf-8'), '["StatusView view called without \'statusmodel\' parameter"]') + self.assertEqual( + str(resp.rendered_content, 'utf-8'), + '["StatusView view called without \'statusmodel\' parameter"]', + ) # Invalid call - not a class with self.assertRaises(NotImplementedError) as e: @@ -107,4 +188,6 @@ class GeneralStateTest(InvenTreeTestCase): # Invalid call - not the right class with self.assertRaises(NotImplementedError) as e: resp = view(rqst, **{StatusView.MODEL_REF: object}) - self.assertEqual(str(e.exception), "`status_class` not a valid StatusCode class") + self.assertEqual( + str(e.exception), "`status_class` not a valid StatusCode class" + ) diff --git a/InvenTree/generic/states/transition.py b/InvenTree/generic/states/transition.py index 882ace3ebb..a764db5a72 100644 --- a/InvenTree/generic/states/transition.py +++ b/InvenTree/generic/states/transition.py @@ -1,4 +1,5 @@ """Classes and functions for plugin controlled object state transitions.""" + import InvenTree.helpers @@ -15,8 +16,10 @@ class TransitionMethod: - The needed functions are implemented """ # Check if a sending fnc is defined - if (not hasattr(self, 'transition')): - raise NotImplementedError('A TransitionMethod must define a `transition` method') + if not hasattr(self, 'transition'): + raise NotImplementedError( + 'A TransitionMethod must define a `transition` method' + ) class TransitionMethodStorageClass: @@ -62,7 +65,9 @@ class StateTransitionMixin: ``` """ - def handle_transition(self, current_state, target_state, instance, default_action, **kwargs): + def handle_transition( + self, current_state, target_state, instance, default_action, **kwargs + ): """Handle a state transition for an object. Args: @@ -74,7 +79,9 @@ class StateTransitionMixin: # Check if there is a custom override function for this transition for override in storage.list: - rslt = override.transition(current_state, target_state, instance, default_action, **kwargs) + rslt = override.transition( + current_state, target_state, instance, default_action, **kwargs + ) if rslt: return rslt diff --git a/InvenTree/generic/templatetags/generic.py b/InvenTree/generic/templatetags/generic.py index af33e9d36b..e57bdec201 100644 --- a/InvenTree/generic/templatetags/generic.py +++ b/InvenTree/generic/templatetags/generic.py @@ -5,6 +5,4 @@ from django import template register = template.Library() from generic.states.tags import status_label # noqa: E402 -__all__ = [ - status_label, -] +__all__ = [status_label] diff --git a/InvenTree/label/admin.py b/InvenTree/label/admin.py index ec4cb3f7dd..4acad6054f 100644 --- a/InvenTree/label/admin.py +++ b/InvenTree/label/admin.py @@ -7,6 +7,7 @@ import label.models class LabelAdmin(admin.ModelAdmin): """Admin class for the various label models""" + list_display = ('name', 'description', 'label', 'filters', 'enabled') diff --git a/InvenTree/label/api.py b/InvenTree/label/api.py index 9421616d73..f73a316918 100644 --- a/InvenTree/label/api.py +++ b/InvenTree/label/api.py @@ -18,8 +18,7 @@ import label.models import label.serializers from InvenTree.api import MetadataView from InvenTree.filters import InvenTreeSearchFilter -from InvenTree.mixins import (ListCreateAPI, RetrieveAPI, - RetrieveUpdateDestroyAPI) +from InvenTree.mixins import ListCreateAPI, RetrieveAPI, RetrieveUpdateDestroyAPI from part.models import Part from plugin.builtin.labels.inventree_label import InvenTreeLabelPlugin from plugin.registry import registry @@ -59,7 +58,7 @@ class LabelFilterMixin: for id in ids: try: valid_ids.append(int(id)) - except (ValueError): + except ValueError: pass # Filter queryset by matching ID values @@ -120,29 +119,18 @@ class LabelListView(LabelFilterMixin, ListCreateAPI): return queryset - filter_backends = [ - DjangoFilterBackend, - InvenTreeSearchFilter - ] + filter_backends = [DjangoFilterBackend, InvenTreeSearchFilter] - filterset_fields = [ - 'enabled', - ] + filterset_fields = ['enabled'] - search_fields = [ - 'name', - 'description', - ] + search_fields = ['name', 'description'] @method_decorator(cache_page(5), name='dispatch') class LabelPrintMixin(LabelFilterMixin): """Mixin for printing labels.""" - rolemap = { - "GET": "view", - "POST": "view", - } + rolemap = {"GET": "view", "POST": "view"} def check_permissions(self, request): """Override request method to GET so that also non superusers can print using a post request.""" @@ -161,7 +149,9 @@ class LabelPrintMixin(LabelFilterMixin): plugin = self.get_plugin(self.request) kwargs.setdefault('context', self.get_serializer_context()) - serializer = plugin.get_printing_options_serializer(self.request, *args, **kwargs) + serializer = plugin.get_printing_options_serializer( + self.request, *args, **kwargs + ) # if no serializer is defined, return an empty serializer if not serializer: @@ -171,8 +161,12 @@ class LabelPrintMixin(LabelFilterMixin): def get(self, request, *args, **kwargs): """Perform a GET request against this endpoint to print labels""" - common.models.InvenTreeUserSetting.set_setting('DEFAULT_' + self.ITEM_KEY.upper() + '_LABEL_TEMPLATE', - self.get_object().pk, None, user=request.user) + common.models.InvenTreeUserSetting.set_setting( + 'DEFAULT_' + self.ITEM_KEY.upper() + '_LABEL_TEMPLATE', + self.get_object().pk, + None, + user=request.user, + ) return self.print(request, self.get_items()) def post(self, request, *args, **kwargs): @@ -206,7 +200,9 @@ class LabelPrintMixin(LabelFilterMixin): raise ValidationError(f"Plugin '{plugin_key}' is not enabled") if not plugin.mixin_enabled("labels"): - raise ValidationError(f"Plugin '{plugin_key}' is not a label printing plugin") + raise ValidationError( + f"Plugin '{plugin_key}' is not a label printing plugin" + ) # Only return the plugin if it is enabled and has the label printing mixin return plugin @@ -228,18 +224,24 @@ class LabelPrintMixin(LabelFilterMixin): raise ValidationError('Label has invalid dimensions') # if the plugin returns a serializer, validate the data - if serializer := plugin.get_printing_options_serializer(request, data=request.data, context=self.get_serializer_context()): + if serializer := plugin.get_printing_options_serializer( + request, data=request.data, context=self.get_serializer_context() + ): serializer.is_valid(raise_exception=True) # At this point, we offload the label(s) to the selected plugin. # The plugin is responsible for handling the request and returning a response. - result = plugin.print_labels(label, items_to_print, request, printing_options=request.data) + result = plugin.print_labels( + label, items_to_print, request, printing_options=request.data + ) if isinstance(result, JsonResponse): result['plugin'] = plugin.plugin_slug() return result - raise ValidationError(f"Plugin '{plugin.plugin_slug()}' returned invalid response type '{type(result)}'") + raise ValidationError( + f"Plugin '{plugin.plugin_slug()}' returned invalid response type '{type(result)}'" + ) class StockItemLabelMixin: @@ -261,16 +263,19 @@ class StockItemLabelList(StockItemLabelMixin, LabelListView): - item: Filter by single stock item - items: Filter by list of stock items """ + pass class StockItemLabelDetail(StockItemLabelMixin, RetrieveUpdateDestroyAPI): """API endpoint for a single StockItemLabel object.""" + pass class StockItemLabelPrint(StockItemLabelMixin, LabelPrintMixin, RetrieveAPI): """API endpoint for printing a StockItemLabel object.""" + pass @@ -293,21 +298,25 @@ class StockLocationLabelList(StockLocationLabelMixin, LabelListView): - location: Filter by a single stock location - locations: Filter by list of stock locations """ + pass class StockLocationLabelDetail(StockLocationLabelMixin, RetrieveUpdateDestroyAPI): """API endpoint for a single StockLocationLabel object.""" + pass class StockLocationLabelPrint(StockLocationLabelMixin, LabelPrintMixin, RetrieveAPI): """API endpoint for printing a StockLocationLabel object.""" + pass class PartLabelMixin: """Mixin for PartLabel endpoints""" + queryset = label.models.PartLabel.objects.all() serializer_class = label.serializers.PartLabelSerializer @@ -317,16 +326,19 @@ class PartLabelMixin: class PartLabelList(PartLabelMixin, LabelListView): """API endpoint for viewing list of PartLabel objects.""" + pass class PartLabelDetail(PartLabelMixin, RetrieveUpdateDestroyAPI): """API endpoint for a single PartLabel object.""" + pass class PartLabelPrint(PartLabelMixin, LabelPrintMixin, RetrieveAPI): """API endpoint for printing a PartLabel object.""" + pass @@ -342,70 +354,147 @@ class BuildLineLabelMixin: class BuildLineLabelList(BuildLineLabelMixin, LabelListView): """API endpoint for viewing a list of BuildLineLabel objects""" + pass class BuildLineLabelDetail(BuildLineLabelMixin, RetrieveUpdateDestroyAPI): """API endpoint for a single BuildLineLabel object""" + pass class BuildLineLabelPrint(BuildLineLabelMixin, LabelPrintMixin, RetrieveAPI): """API endpoint for printing a BuildLineLabel object""" + pass label_api_urls = [ - # Stock item labels - re_path(r'stock/', include([ - # Detail views - path(r'/', include([ - re_path(r'print/?', StockItemLabelPrint.as_view(), name='api-stockitem-label-print'), - re_path(r'metadata/', MetadataView.as_view(), {'model': label.models.StockItemLabel}, name='api-stockitem-label-metadata'), - re_path(r'^.*$', StockItemLabelDetail.as_view(), name='api-stockitem-label-detail'), - ])), - - # List view - re_path(r'^.*$', StockItemLabelList.as_view(), name='api-stockitem-label-list'), - ])), - + re_path( + r'stock/', + include([ + # Detail views + path( + r'/', + include([ + re_path( + r'print/?', + StockItemLabelPrint.as_view(), + name='api-stockitem-label-print', + ), + re_path( + r'metadata/', + MetadataView.as_view(), + {'model': label.models.StockItemLabel}, + name='api-stockitem-label-metadata', + ), + re_path( + r'^.*$', + StockItemLabelDetail.as_view(), + name='api-stockitem-label-detail', + ), + ]), + ), + # List view + re_path( + r'^.*$', StockItemLabelList.as_view(), name='api-stockitem-label-list' + ), + ]), + ), # Stock location labels - re_path(r'location/', include([ - # Detail views - path(r'/', include([ - re_path(r'print/?', StockLocationLabelPrint.as_view(), name='api-stocklocation-label-print'), - re_path(r'metadata/', MetadataView.as_view(), {'model': label.models.StockLocationLabel}, name='api-stocklocation-label-metadata'), - re_path(r'^.*$', StockLocationLabelDetail.as_view(), name='api-stocklocation-label-detail'), - ])), - - # List view - re_path(r'^.*$', StockLocationLabelList.as_view(), name='api-stocklocation-label-list'), - ])), - + re_path( + r'location/', + include([ + # Detail views + path( + r'/', + include([ + re_path( + r'print/?', + StockLocationLabelPrint.as_view(), + name='api-stocklocation-label-print', + ), + re_path( + r'metadata/', + MetadataView.as_view(), + {'model': label.models.StockLocationLabel}, + name='api-stocklocation-label-metadata', + ), + re_path( + r'^.*$', + StockLocationLabelDetail.as_view(), + name='api-stocklocation-label-detail', + ), + ]), + ), + # List view + re_path( + r'^.*$', + StockLocationLabelList.as_view(), + name='api-stocklocation-label-list', + ), + ]), + ), # Part labels - re_path(r'^part/', include([ - # Detail views - path(r'/', include([ - re_path(r'^print/', PartLabelPrint.as_view(), name='api-part-label-print'), - re_path(r'^metadata/', MetadataView.as_view(), {'model': label.models.PartLabel}, name='api-part-label-metadata'), - re_path(r'^.*$', PartLabelDetail.as_view(), name='api-part-label-detail'), - ])), - - # List view - re_path(r'^.*$', PartLabelList.as_view(), name='api-part-label-list'), - ])), - + re_path( + r'^part/', + include([ + # Detail views + path( + r'/', + include([ + re_path( + r'^print/', + PartLabelPrint.as_view(), + name='api-part-label-print', + ), + re_path( + r'^metadata/', + MetadataView.as_view(), + {'model': label.models.PartLabel}, + name='api-part-label-metadata', + ), + re_path( + r'^.*$', PartLabelDetail.as_view(), name='api-part-label-detail' + ), + ]), + ), + # List view + re_path(r'^.*$', PartLabelList.as_view(), name='api-part-label-list'), + ]), + ), # BuildLine labels - re_path(r'^buildline/', include([ - # Detail views - path(r'/', include([ - re_path(r'^print/', BuildLineLabelPrint.as_view(), name='api-buildline-label-print'), - re_path(r'^metadata/', MetadataView.as_view(), {'model': label.models.BuildLineLabel}, name='api-buildline-label-metadata'), - re_path(r'^.*$', BuildLineLabelDetail.as_view(), name='api-buildline-label-detail'), - ])), - - # List view - re_path(r'^.*$', BuildLineLabelList.as_view(), name='api-buildline-label-list'), - ])), + re_path( + r'^buildline/', + include([ + # Detail views + path( + r'/', + include([ + re_path( + r'^print/', + BuildLineLabelPrint.as_view(), + name='api-buildline-label-print', + ), + re_path( + r'^metadata/', + MetadataView.as_view(), + {'model': label.models.BuildLineLabel}, + name='api-buildline-label-metadata', + ), + re_path( + r'^.*$', + BuildLineLabelDetail.as_view(), + name='api-buildline-label-detail', + ), + ]), + ), + # List view + re_path( + r'^.*$', BuildLineLabelList.as_view(), name='api-buildline-label-list' + ), + ]), + ), ] diff --git a/InvenTree/label/apps.py b/InvenTree/label/apps.py index 9455d34a8b..d7677f68b0 100644 --- a/InvenTree/label/apps.py +++ b/InvenTree/label/apps.py @@ -12,8 +12,12 @@ from django.conf import settings from django.core.exceptions import AppRegistryNotReady from django.db.utils import IntegrityError, OperationalError, ProgrammingError -from InvenTree.ready import (canAppAccessDatabase, isImportingData, - isInMainThread, isPluginRegistryLoaded) +from InvenTree.ready import ( + canAppAccessDatabase, + isImportingData, + isInMainThread, + isPluginRegistryLoaded, +) logger = logging.getLogger("inventree") @@ -43,14 +47,22 @@ class LabelConfig(AppConfig): if canAppAccessDatabase(allow_test=False) and not isImportingData(): try: self.create_labels() # pragma: no cover - except (AppRegistryNotReady, IntegrityError, OperationalError, ProgrammingError): + except ( + AppRegistryNotReady, + IntegrityError, + OperationalError, + ProgrammingError, + ): # Database might not yet be ready - warnings.warn('Database was not ready for creating labels', stacklevel=2) + warnings.warn( + 'Database was not ready for creating labels', stacklevel=2 + ) def create_labels(self): """Create all default templates.""" # Test if models are ready import label.models + assert bool(label.models.StockLocationLabel is not None) # Create the categories @@ -64,7 +76,7 @@ class LabelConfig(AppConfig): 'description': 'Simple QR code label', 'width': 24, 'height': 24, - }, + } ], ) @@ -85,8 +97,8 @@ class LabelConfig(AppConfig): 'description': 'Label with QR code and name of location', 'width': 50, 'height': 24, - } - ] + }, + ], ) self.create_labels_category( @@ -107,7 +119,7 @@ class LabelConfig(AppConfig): 'width': 70, 'height': 24, }, - ] + ], ) self.create_labels_category( @@ -120,24 +132,16 @@ class LabelConfig(AppConfig): 'description': 'Example build line label', 'width': 125, 'height': 48, - }, - ] + } + ], ) def create_labels_category(self, model, ref_name, labels): """Create folder and database entries for the default templates, if they do not already exist.""" # Create root dir for templates - src_dir = Path(__file__).parent.joinpath( - 'templates', - 'label', - ref_name, - ) + src_dir = Path(__file__).parent.joinpath('templates', 'label', ref_name) - dst_dir = settings.MEDIA_ROOT.joinpath( - 'label', - 'inventree', - ref_name, - ) + dst_dir = settings.MEDIA_ROOT.joinpath('label', 'inventree', ref_name) if not dst_dir.exists(): logger.info("Creating required directory: '%s'", dst_dir) @@ -149,12 +153,7 @@ class LabelConfig(AppConfig): def create_template_label(self, model, src_dir, ref_name, label): """Ensure a label template is in place.""" - filename = os.path.join( - 'label', - 'inventree', - ref_name, - label['file'] - ) + filename = os.path.join('label', 'inventree', ref_name, label['file']) src_file = src_dir.joinpath(label['file']) dst_file = settings.MEDIA_ROOT.joinpath(filename) @@ -185,7 +184,10 @@ class LabelConfig(AppConfig): if model.objects.filter(label=filename).exists(): return # pragma: no cover except Exception: - logger.exception("Failed to query label for '%s' - you should run 'invoke update' first!", filename) + logger.exception( + "Failed to query label for '%s' - you should run 'invoke update' first!", + filename, + ) logger.info("Creating entry for %s '%s'", model, label['name']) diff --git a/InvenTree/label/models.py b/InvenTree/label/models.py index 65a0306ce1..89d8d0baf3 100644 --- a/InvenTree/label/models.py +++ b/InvenTree/label/models.py @@ -112,14 +112,13 @@ class LabelTemplate(MetadataMixin, models.Model): return f"{self.name} - {self.description}" name = models.CharField( - blank=False, max_length=100, - verbose_name=_('Name'), - help_text=_('Label name'), + blank=False, max_length=100, verbose_name=_('Name'), help_text=_('Label name') ) description = models.CharField( max_length=250, - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Description'), help_text=_('Label description'), ) @@ -127,7 +126,8 @@ class LabelTemplate(MetadataMixin, models.Model): label = models.FileField( upload_to=rename_label, unique=True, - blank=False, null=False, + blank=False, + null=False, verbose_name=_('Label'), help_text=_('Label template file'), validators=[FileExtensionValidator(allowed_extensions=['html'])], @@ -143,14 +143,14 @@ class LabelTemplate(MetadataMixin, models.Model): default=50, verbose_name=_('Width [mm]'), help_text=_('Label width, specified in mm'), - validators=[MinValueValidator(2)] + validators=[MinValueValidator(2)], ) height = models.FloatField( default=20, verbose_name=_('Height [mm]'), help_text=_('Label height, specified in mm'), - validators=[MinValueValidator(2)] + validators=[MinValueValidator(2)], ) filename_pattern = models.CharField( @@ -249,11 +249,7 @@ class LabelTemplate(MetadataMixin, models.Model): context = self.context(request, **kwargs) - return render_to_string( - self.template_name, - context, - request - ) + return render_to_string(self.template_name, context, request) def render(self, request, target_object=None, **kwargs): """Render the label template to a PDF file. @@ -272,13 +268,10 @@ class LabelTemplate(MetadataMixin, models.Model): base_url=request.build_absolute_uri("/"), presentational_hints=True, filename=self.generate_filename(request), - **kwargs + **kwargs, ) - return wp.render_to_response( - context, - **kwargs - ) + return wp.render_to_response(context, **kwargs) class LabelOutput(models.Model): @@ -293,22 +286,14 @@ class LabelOutput(models.Model): # File will be stored in a subdirectory label = models.FileField( - upload_to=rename_label_output, - unique=True, blank=False, null=False, + upload_to=rename_label_output, unique=True, blank=False, null=False ) # Creation date of label output - created = models.DateField( - auto_now_add=True, - editable=False, - ) + created = models.DateField(auto_now_add=True, editable=False) # User who generated the label - user = models.ForeignKey( - User, - on_delete=models.SET_NULL, - blank=True, null=True, - ) + user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True) class StockItemLabel(LabelTemplate): @@ -322,12 +307,11 @@ class StockItemLabel(LabelTemplate): SUBDIR = "stockitem" filters = models.CharField( - blank=True, max_length=250, + blank=True, + max_length=250, help_text=_('Query filters (comma-separated list of key=value pairs)'), verbose_name=_('Filters'), - validators=[ - validate_stock_item_filters - ] + validators=[validate_stock_item_filters], ) def get_context_data(self, request): @@ -348,7 +332,6 @@ class StockItemLabel(LabelTemplate): 'qr_url': request.build_absolute_uri(stock_item.get_absolute_url()), 'tests': stock_item.testResultMap(), 'parameters': stock_item.part.parameters_map(), - } @@ -363,21 +346,18 @@ class StockLocationLabel(LabelTemplate): SUBDIR = "stocklocation" filters = models.CharField( - blank=True, max_length=250, + blank=True, + max_length=250, help_text=_('Query filters (comma-separated list of key=value pairs)'), verbose_name=_('Filters'), - validators=[ - validate_stock_location_filters] + validators=[validate_stock_location_filters], ) def get_context_data(self, request): """Generate context data for each provided StockLocation.""" location = self.object_to_print - return { - 'location': location, - 'qr_data': location.format_barcode(brief=True), - } + return {'location': location, 'qr_data': location.format_barcode(brief=True)} class PartLabel(LabelTemplate): @@ -391,12 +371,11 @@ class PartLabel(LabelTemplate): SUBDIR = 'part' filters = models.CharField( - blank=True, max_length=250, + blank=True, + max_length=250, help_text=_('Query filters (comma-separated list of key=value pairs)'), verbose_name=_('Filters'), - validators=[ - validate_part_filters - ] + validators=[validate_part_filters], ) def get_context_data(self, request): @@ -427,12 +406,11 @@ class BuildLineLabel(LabelTemplate): SUBDIR = 'buildline' filters = models.CharField( - blank=True, max_length=250, + blank=True, + max_length=250, help_text=_('Query filters (comma-separated list of key=value pairs)'), verbose_name=_('Filters'), - validators=[ - validate_build_line_filters - ] + validators=[validate_build_line_filters], ) def get_context_data(self, request): diff --git a/InvenTree/label/serializers.py b/InvenTree/label/serializers.py index ecceb8ed69..c2c0d3fda0 100644 --- a/InvenTree/label/serializers.py +++ b/InvenTree/label/serializers.py @@ -1,8 +1,10 @@ """API serializers for the label app""" import label.models -from InvenTree.serializers import (InvenTreeAttachmentSerializerField, - InvenTreeModelSerializer) +from InvenTree.serializers import ( + InvenTreeAttachmentSerializerField, + InvenTreeModelSerializer, +) class LabelSerializerBase(InvenTreeModelSerializer): @@ -13,14 +15,7 @@ class LabelSerializerBase(InvenTreeModelSerializer): @staticmethod def label_fields(): """Generic serializer fields for a label template""" - return [ - 'pk', - 'name', - 'description', - 'label', - 'filters', - 'enabled', - ] + return ['pk', 'name', 'description', 'label', 'filters', 'enabled'] class StockItemLabelSerializer(LabelSerializerBase): diff --git a/InvenTree/label/test_api.py b/InvenTree/label/test_api.py index 2bba71f723..fe44e84e0e 100644 --- a/InvenTree/label/test_api.py +++ b/InvenTree/label/test_api.py @@ -8,17 +8,9 @@ from InvenTree.unit_test import InvenTreeAPITestCase class TestReportTests(InvenTreeAPITestCase): """Tests for the StockItem TestReport templates.""" - fixtures = [ - 'category', - 'part', - 'location', - 'stock', - ] + fixtures = ['category', 'part', 'location', 'stock'] - roles = [ - 'stock.view', - 'stock_location.view', - ] + roles = ['stock.view', 'stock_location.view'] list_url = reverse('api-stockitem-testreport-list') @@ -42,22 +34,10 @@ class TestReportTests(InvenTreeAPITestCase): self.assertEqual(len(response), 0) # TODO - Add some tests to this response - response = self.do_list( - { - 'item': 10, - } - ) + response = self.do_list({'item': 10}) # TODO - Add some tests to this response - response = self.do_list( - { - 'item': 100000, - } - ) + response = self.do_list({'item': 100000}) # TODO - Add some tests to this response - response = self.do_list( - { - 'items': [10, 11, 12], - } - ) + response = self.do_list({'items': [10, 11, 12]}) diff --git a/InvenTree/label/tests.py b/InvenTree/label/tests.py index ac82639388..9f623a873b 100644 --- a/InvenTree/label/tests.py +++ b/InvenTree/label/tests.py @@ -24,12 +24,7 @@ from .models import PartLabel, StockItemLabel, StockLocationLabel class LabelTest(InvenTreeAPITestCase): """Unit test class for label models""" - fixtures = [ - 'category', - 'part', - 'location', - 'stock' - ] + fixtures = ['category', 'part', 'location', 'stock'] @classmethod def setUpTestData(cls): @@ -49,12 +44,9 @@ class LabelTest(InvenTreeAPITestCase): def test_default_files(self): """Test that label files exist in the MEDIA directory.""" + def test_subdir(ref_name): - item_dir = settings.MEDIA_ROOT.joinpath( - 'label', - 'inventree', - ref_name, - ) + item_dir = settings.MEDIA_ROOT.joinpath('label', 'inventree', ref_name) self.assertTrue(len([item_dir.iterdir()]) > 0) test_subdir('stockitem') @@ -119,10 +111,7 @@ class LabelTest(InvenTreeAPITestCase): # Construct a label template label = PartLabel.objects.create( - name='test', - description='Test label', - enabled=True, - label=template, + name='test', description='Test label', enabled=True, label=template ) # Ensure we are in "debug" mode (so the report is generated as HTML) diff --git a/InvenTree/order/admin.py b/InvenTree/order/admin.py index b432e36154..f662831e17 100644 --- a/InvenTree/order/admin.py +++ b/InvenTree/order/admin.py @@ -50,31 +50,28 @@ class PriceResourceMixin: # region general classes class GeneralExtraLineAdmin: """Admin class template for the 'ExtraLineItem' models""" - list_display = ( - 'order', - 'quantity', - 'reference' - ) - search_fields = [ - 'order__reference', - 'order__customer__name', - 'reference', - ] + list_display = ('order', 'quantity', 'reference') - autocomplete_fields = ('order', ) + search_fields = ['order__reference', 'order__customer__name', 'reference'] + + autocomplete_fields = ('order',) class GeneralExtraLineMeta: """Metaclass template for the 'ExtraLineItem' models""" + skip_unchanged = True report_skipped = False clean_model_instances = True + + # endregion class PurchaseOrderLineItemInlineAdmin(admin.StackedInline): """Inline admin class for the PurchaseOrderLineItem model""" + model = models.PurchaseOrderLineItem extra = 0 @@ -82,27 +79,13 @@ class PurchaseOrderLineItemInlineAdmin(admin.StackedInline): class PurchaseOrderAdmin(ImportExportModelAdmin): """Admin class for the PurchaseOrder model""" - exclude = [ - 'reference_int', - ] + exclude = ['reference_int'] - list_display = ( - 'reference', - 'supplier', - 'status', - 'description', - 'creation_date' - ) + list_display = ('reference', 'supplier', 'status', 'description', 'creation_date') - search_fields = [ - 'reference', - 'supplier__name', - 'description', - ] + search_fields = ['reference', 'supplier__name', 'description'] - inlines = [ - PurchaseOrderLineItemInlineAdmin - ] + inlines = [PurchaseOrderLineItemInlineAdmin] autocomplete_fields = ('supplier',) @@ -110,44 +93,37 @@ class PurchaseOrderAdmin(ImportExportModelAdmin): class SalesOrderAdmin(ImportExportModelAdmin): """Admin class for the SalesOrder model""" - exclude = [ - 'reference_int', - ] + exclude = ['reference_int'] - list_display = ( - 'reference', - 'customer', - 'status', - 'description', - 'creation_date', - ) + list_display = ('reference', 'customer', 'status', 'description', 'creation_date') - search_fields = [ - 'reference', - 'customer__name', - 'description', - ] + search_fields = ['reference', 'customer__name', 'description'] autocomplete_fields = ('customer',) -class PurchaseOrderResource(ProjectCodeResourceMixin, TotalPriceResourceMixin, InvenTreeResource): +class PurchaseOrderResource( + ProjectCodeResourceMixin, TotalPriceResourceMixin, InvenTreeResource +): """Class for managing import / export of PurchaseOrder data.""" class Meta: """Metaclass""" + model = models.PurchaseOrder skip_unchanged = True clean_model_instances = True - exclude = [ - 'metadata', - ] + exclude = ['metadata'] # Add number of line items - line_items = Field(attribute='line_count', widget=widgets.IntegerWidget(), readonly=True) + 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) + overdue = Field( + attribute='is_overdue', widget=widgets.BooleanWidget(), readonly=True + ) class PurchaseOrderLineItemResource(PriceResourceMixin, InvenTreeResource): @@ -155,6 +131,7 @@ class PurchaseOrderLineItemResource(PriceResourceMixin, InvenTreeResource): class Meta: """Metaclass""" + model = models.PurchaseOrderLineItem skip_unchanged = True report_skipped = False @@ -184,23 +161,28 @@ class PurchaseOrderExtraLineResource(PriceResourceMixin, InvenTreeResource): model = models.PurchaseOrderExtraLine -class SalesOrderResource(ProjectCodeResourceMixin, TotalPriceResourceMixin, InvenTreeResource): +class SalesOrderResource( + ProjectCodeResourceMixin, TotalPriceResourceMixin, InvenTreeResource +): """Class for managing import / export of SalesOrder data.""" class Meta: """Metaclass options""" + model = models.SalesOrder skip_unchanged = True clean_model_instances = True - exclude = [ - 'metadata', - ] + exclude = ['metadata'] # Add number of line items - line_items = Field(attribute='line_count', widget=widgets.IntegerWidget(), readonly=True) + 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) + overdue = Field( + attribute='is_overdue', widget=widgets.BooleanWidget(), readonly=True + ) class SalesOrderLineItemResource(PriceResourceMixin, InvenTreeResource): @@ -208,6 +190,7 @@ class SalesOrderLineItemResource(PriceResourceMixin, InvenTreeResource): class Meta: """Metaclass options""" + model = models.SalesOrderLineItem skip_unchanged = True report_skipped = False @@ -236,6 +219,7 @@ class SalesOrderExtraLineResource(PriceResourceMixin, InvenTreeResource): class Meta(GeneralExtraLineMeta): """Metaclass options.""" + model = models.SalesOrderExtraLine @@ -244,20 +228,16 @@ class PurchaseOrderLineItemAdmin(ImportExportModelAdmin): resource_class = PurchaseOrderLineItemResource - list_display = ( - 'order', - 'part', - 'quantity', - 'reference' - ) + list_display = ('order', 'part', 'quantity', 'reference') search_fields = ('reference',) - autocomplete_fields = ('order', 'part', 'destination',) + autocomplete_fields = ('order', 'part', 'destination') class PurchaseOrderExtraLineAdmin(GeneralExtraLineAdmin, ImportExportModelAdmin): """Admin class for the PurchaseOrderExtraLine model""" + resource_class = PurchaseOrderExtraLineResource @@ -266,12 +246,7 @@ class SalesOrderLineItemAdmin(ImportExportModelAdmin): resource_class = SalesOrderLineItemResource - list_display = ( - 'order', - 'part', - 'quantity', - 'reference' - ) + list_display = ('order', 'part', 'quantity', 'reference') search_fields = [ 'part__name', @@ -280,28 +255,21 @@ class SalesOrderLineItemAdmin(ImportExportModelAdmin): 'reference', ] - autocomplete_fields = ('order', 'part',) + autocomplete_fields = ('order', 'part') class SalesOrderExtraLineAdmin(GeneralExtraLineAdmin, ImportExportModelAdmin): """Admin class for the SalesOrderExtraLine model""" + resource_class = SalesOrderExtraLineResource class SalesOrderShipmentAdmin(ImportExportModelAdmin): """Admin class for the SalesOrderShipment model""" - list_display = [ - 'order', - 'shipment_date', - 'reference', - ] + list_display = ['order', 'shipment_date', 'reference'] - search_fields = [ - 'reference', - 'order__reference', - 'order__customer__name', - ] + search_fields = ['reference', 'order__reference', 'order__customer__name'] autocomplete_fields = ('order',) @@ -309,50 +277,35 @@ class SalesOrderShipmentAdmin(ImportExportModelAdmin): class SalesOrderAllocationAdmin(ImportExportModelAdmin): """Admin class for the SalesOrderAllocation model""" - list_display = ( - 'line', - 'item', - 'quantity' - ) + list_display = ('line', 'item', 'quantity') - autocomplete_fields = ('line', 'shipment', 'item',) + autocomplete_fields = ('line', 'shipment', 'item') -class ReturnOrderResource(ProjectCodeResourceMixin, TotalPriceResourceMixin, InvenTreeResource): +class ReturnOrderResource( + ProjectCodeResourceMixin, TotalPriceResourceMixin, InvenTreeResource +): """Class for managing import / export of ReturnOrder data""" class Meta: """Metaclass options""" + model = models.ReturnOrder skip_unchanged = True clean_model_instances = True - exclude = [ - 'metadata', - ] + exclude = ['metadata'] class ReturnOrderAdmin(ImportExportModelAdmin): """Admin class for the ReturnOrder model""" - exclude = [ - 'reference_int', - ] + exclude = ['reference_int'] - list_display = [ - 'reference', - 'customer', - 'status', - ] + list_display = ['reference', 'customer', 'status'] - search_fields = [ - 'reference', - 'customer__name', - 'description', - ] + search_fields = ['reference', 'customer__name', 'description'] - autocomplete_fields = [ - 'customer', - ] + autocomplete_fields = ['customer'] class ReturnOrderLineItemResource(PriceResourceMixin, InvenTreeResource): @@ -360,6 +313,7 @@ class ReturnOrderLineItemResource(PriceResourceMixin, InvenTreeResource): class Meta: """Metaclass options""" + model = models.ReturnOrderLineItem skip_unchanged = True report_skipped = False @@ -371,11 +325,7 @@ class ReturnOrderLineItemAdmin(ImportExportModelAdmin): resource_class = ReturnOrderLineItemResource - list_display = [ - 'order', - 'item', - 'reference', - ] + list_display = ['order', 'item', 'reference'] class ReturnOrderExtraLineClass(PriceResourceMixin, InvenTreeResource): @@ -383,11 +333,13 @@ class ReturnOrderExtraLineClass(PriceResourceMixin, InvenTreeResource): class Meta(GeneralExtraLineMeta): """Metaclass options""" + model = models.ReturnOrderExtraLine class ReturnOrdeerExtraLineAdmin(GeneralExtraLineAdmin, ImportExportModelAdmin): """Admin class for the ReturnOrderExtraLine model""" + resource_class = ReturnOrderExtraLineClass diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index fd7bade3d7..e503efcd74 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -17,22 +17,34 @@ import common.models as common_models from common.settings import settings from company.models import SupplierPart from generic.states.api import StatusView -from InvenTree.api import (APIDownloadMixin, AttachmentMixin, - ListCreateDestroyAPIView, MetadataView) +from InvenTree.api import ( + APIDownloadMixin, + AttachmentMixin, + ListCreateDestroyAPIView, + MetadataView, +) from InvenTree.filters import SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER_ALIAS from InvenTree.helpers import DownloadFile, str2bool from InvenTree.helpers_model import construct_absolute_url, get_base_url -from InvenTree.mixins import (CreateAPI, ListAPI, ListCreateAPI, - RetrieveUpdateDestroyAPI) -from InvenTree.status_codes import (PurchaseOrderStatus, - PurchaseOrderStatusGroups, - ReturnOrderLineStatus, ReturnOrderStatus, - SalesOrderStatus, SalesOrderStatusGroups) +from InvenTree.mixins import CreateAPI, ListAPI, ListCreateAPI, RetrieveUpdateDestroyAPI +from InvenTree.status_codes import ( + PurchaseOrderStatus, + PurchaseOrderStatusGroups, + ReturnOrderLineStatus, + ReturnOrderStatus, + SalesOrderStatus, + SalesOrderStatusGroups, +) from order import models, serializers -from order.admin import (PurchaseOrderExtraLineResource, - PurchaseOrderLineItemResource, PurchaseOrderResource, - ReturnOrderResource, SalesOrderExtraLineResource, - SalesOrderLineItemResource, SalesOrderResource) +from order.admin import ( + PurchaseOrderExtraLineResource, + PurchaseOrderLineItemResource, + PurchaseOrderResource, + ReturnOrderResource, + SalesOrderExtraLineResource, + SalesOrderLineItemResource, + SalesOrderResource, +) from part.models import Part from users.models import Owner @@ -57,30 +69,17 @@ class GeneralExtraLineList(APIDownloadMixin): """Return the annotated queryset for this endpoint""" queryset = super().get_queryset(*args, **kwargs) - queryset = queryset.prefetch_related( - 'order', - ) + queryset = queryset.prefetch_related('order') return queryset filter_backends = SEARCH_ORDER_FILTER - ordering_fields = [ - 'quantity', - 'note', - 'reference', - ] + ordering_fields = ['quantity', 'note', 'reference'] - search_fields = [ - 'quantity', - 'note', - 'reference', - 'description', - ] + search_fields = ['quantity', 'note', 'reference', 'description'] - filterset_fields = [ - 'order', - ] + filterset_fields = ['order'] class OrderFilter(rest_filters.FilterSet): @@ -95,12 +94,12 @@ class OrderFilter(rest_filters.FilterSet): # Exact match for reference reference = rest_filters.CharFilter( - label='Filter by exact reference', - field_name='reference', - lookup_expr="iexact" + label='Filter by exact reference', field_name='reference', lookup_expr="iexact" ) - assigned_to_me = rest_filters.BooleanFilter(label='assigned_to_me', method='filter_assigned_to_me') + assigned_to_me = rest_filters.BooleanFilter( + label='assigned_to_me', method='filter_assigned_to_me' + ) def filter_assigned_to_me(self, queryset, name, value): """Filter by orders which are assigned to the current user.""" @@ -122,7 +121,9 @@ class OrderFilter(rest_filters.FilterSet): return queryset.filter(self.Meta.model.overdue_filter()) return queryset.exclude(self.Meta.model.overdue_filter()) - outstanding = rest_filters.BooleanFilter(label='outstanding', method='filter_outstanding') + outstanding = rest_filters.BooleanFilter( + label='outstanding', method='filter_outstanding' + ) def filter_outstanding(self, queryset, name, value): """Generic filter for determining if an order is 'outstanding'""" @@ -131,11 +132,12 @@ class OrderFilter(rest_filters.FilterSet): return queryset.exclude(status__in=self.Meta.model.get_status_class().OPEN) project_code = rest_filters.ModelChoiceFilter( - queryset=common_models.ProjectCode.objects.all(), - field_name='project_code' + queryset=common_models.ProjectCode.objects.all(), field_name='project_code' ) - has_project_code = rest_filters.BooleanFilter(label='has_project_code', method='filter_has_project_code') + has_project_code = rest_filters.BooleanFilter( + label='has_project_code', method='filter_has_project_code' + ) def filter_has_project_code(self, queryset, name, value): """Filter by whether or not the order has a project code""" @@ -148,9 +150,13 @@ class LineItemFilter(rest_filters.FilterSet): """Base class for custom API filters for order line item list(s)""" # Filter by order status - order_status = rest_filters.NumberFilter(label='order_status', field_name='order__status') + order_status = rest_filters.NumberFilter( + label='order_status', field_name='order__status' + ) - has_pricing = rest_filters.BooleanFilter(label="Has Pricing", method='filter_has_pricing') + has_pricing = rest_filters.BooleanFilter( + label="Has Pricing", method='filter_has_pricing' + ) def filter_has_pricing(self, queryset, name, value): """Filter by whether or not the line item has pricing information""" @@ -168,9 +174,7 @@ class PurchaseOrderFilter(OrderFilter): """Metaclass options.""" model = models.PurchaseOrder - fields = [ - 'supplier', - ] + fields = ['supplier'] class PurchaseOrderMixin: @@ -182,7 +186,9 @@ class PurchaseOrderMixin: def get_serializer(self, *args, **kwargs): """Return the serializer instance for this endpoint""" try: - kwargs['supplier_detail'] = str2bool(self.request.query_params.get('supplier_detail', False)) + kwargs['supplier_detail'] = str2bool( + self.request.query_params.get('supplier_detail', False) + ) except AttributeError: pass @@ -195,10 +201,7 @@ class PurchaseOrderMixin: """Return the annotated queryset for this endpoint""" queryset = super().get_queryset(*args, **kwargs) - queryset = queryset.prefetch_related( - 'supplier', - 'lines', - ) + queryset = queryset.prefetch_related('supplier', 'lines') queryset = serializers.PurchaseOrderSerializer.annotate_queryset(queryset) @@ -227,7 +230,7 @@ class PurchaseOrderList(PurchaseOrderMixin, APIDownloadMixin, ListCreateAPI): duplicate_order = models.PurchaseOrder.objects.get(pk=duplicate_order) except (ValueError, models.PurchaseOrder.DoesNotExist): raise ValidationError({ - 'duplicate_order': [_('No matching purchase order found')], + 'duplicate_order': [_('No matching purchase order found')] }) serializer = self.get_serializer(data=data) @@ -240,7 +243,6 @@ class PurchaseOrderList(PurchaseOrderMixin, APIDownloadMixin, ListCreateAPI): # Duplicate line items from other order if required if duplicate_order is not None: - if duplicate_line_items: for line in duplicate_order.lines.all(): # Copy the line across to the new order @@ -259,7 +261,9 @@ class PurchaseOrderList(PurchaseOrderMixin, APIDownloadMixin, ListCreateAPI): line.save() headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + return Response( + serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) def download_queryset(self, queryset, export_format): """Download the filtered queryset as a file""" @@ -284,7 +288,9 @@ class PurchaseOrderList(PurchaseOrderMixin, APIDownloadMixin, ListCreateAPI): if part is not None: try: part = Part.objects.get(pk=part) - queryset = queryset.filter(id__in=[p.id for p in part.purchase_orders()]) + queryset = queryset.filter( + id__in=[p.id for p in part.purchase_orders()] + ) except (Part.DoesNotExist, ValueError): pass @@ -294,7 +300,9 @@ class PurchaseOrderList(PurchaseOrderMixin, APIDownloadMixin, ListCreateAPI): if supplier_part is not None: try: supplier_part = SupplierPart.objects.get(pk=supplier_part) - queryset = queryset.filter(id__in=[p.id for p in supplier_part.purchase_orders()]) + queryset = queryset.filter( + id__in=[p.id for p in supplier_part.purchase_orders()] + ) except (ValueError, SupplierPart.DoesNotExist): pass @@ -339,6 +347,7 @@ class PurchaseOrderList(PurchaseOrderMixin, APIDownloadMixin, ListCreateAPI): class PurchaseOrderDetail(PurchaseOrderMixin, RetrieveUpdateDestroyAPI): """API endpoint for detail view of a PurchaseOrder object.""" + pass @@ -353,7 +362,9 @@ class PurchaseOrderContextMixin: # Pass the purchase order through to the serializer for validation try: - context['order'] = models.PurchaseOrder.objects.get(pk=self.kwargs.get('pk', None)) + context['order'] = models.PurchaseOrder.objects.get( + pk=self.kwargs.get('pk', None) + ) except Exception: pass @@ -408,12 +419,10 @@ class PurchaseOrderLineItemFilter(LineItemFilter): class Meta: """Metaclass options.""" + price_field = 'purchase_price' model = models.PurchaseOrderLineItem - fields = [ - 'order', - 'part', - ] + fields = ['order', 'part'] pending = rest_filters.BooleanFilter(label='pending', method='filter_pending') @@ -435,7 +444,9 @@ class PurchaseOrderLineItemFilter(LineItemFilter): if str2bool(value): return queryset.filter(q) # Only count "pending" orders - return queryset.exclude(q).filter(order__status__in=PurchaseOrderStatusGroups.OPEN) + return queryset.exclude(q).filter( + order__status__in=PurchaseOrderStatusGroups.OPEN + ) class PurchaseOrderLineItemMixin: @@ -448,15 +459,21 @@ class PurchaseOrderLineItemMixin: """Return annotated queryset for this endpoint""" queryset = super().get_queryset(*args, **kwargs) - queryset = serializers.PurchaseOrderLineItemSerializer.annotate_queryset(queryset) + queryset = serializers.PurchaseOrderLineItemSerializer.annotate_queryset( + queryset + ) return queryset def get_serializer(self, *args, **kwargs): """Return serializer instance for this endpoint""" try: - kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', False)) - kwargs['order_detail'] = str2bool(self.request.query_params.get('order_detail', False)) + kwargs['part_detail'] = str2bool( + self.request.query_params.get('part_detail', False) + ) + kwargs['order_detail'] = str2bool( + self.request.query_params.get('order_detail', False) + ) except AttributeError: pass @@ -465,7 +482,9 @@ class PurchaseOrderLineItemMixin: return self.serializer_class(*args, **kwargs) -class PurchaseOrderLineItemList(PurchaseOrderLineItemMixin, APIDownloadMixin, ListCreateDestroyAPIView): +class PurchaseOrderLineItemList( + PurchaseOrderLineItemMixin, APIDownloadMixin, ListCreateDestroyAPIView +): """API endpoint for accessing a list of PurchaseOrderLineItem objects. - GET: Return a list of PurchaseOrder Line Item objects @@ -534,6 +553,7 @@ class PurchaseOrderLineItemList(PurchaseOrderLineItemMixin, APIDownloadMixin, Li class PurchaseOrderLineItemDetail(PurchaseOrderLineItemMixin, RetrieveUpdateDestroyAPI): """Detail API endpoint for PurchaseOrderLineItem object.""" + pass @@ -565,9 +585,7 @@ class SalesOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView): queryset = models.SalesOrderAttachment.objects.all() serializer_class = serializers.SalesOrderAttachmentSerializer - filterset_fields = [ - 'order', - ] + filterset_fields = ['order'] class SalesOrderAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI): @@ -584,9 +602,7 @@ class SalesOrderFilter(OrderFilter): """Metaclass options.""" model = models.SalesOrder - fields = [ - 'customer', - ] + fields = ['customer'] class SalesOrderMixin: @@ -598,7 +614,9 @@ class SalesOrderMixin: def get_serializer(self, *args, **kwargs): """Return serializer instance for this endpoint""" try: - kwargs['customer_detail'] = str2bool(self.request.query_params.get('customer_detail', False)) + kwargs['customer_detail'] = str2bool( + self.request.query_params.get('customer_detail', False) + ) except AttributeError: pass @@ -611,10 +629,7 @@ class SalesOrderMixin: """Return annotated queryset for this endpoint""" queryset = super().get_queryset(*args, **kwargs) - queryset = queryset.prefetch_related( - 'customer', - 'lines' - ) + queryset = queryset.prefetch_related('customer', 'lines') queryset = serializers.SalesOrderSerializer.annotate_queryset(queryset) @@ -640,7 +655,9 @@ class SalesOrderList(SalesOrderMixin, APIDownloadMixin, ListCreateAPI): item.save() headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + return Response( + serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) def download_queryset(self, queryset, export_format): """Download this queryset as a file""" @@ -685,9 +702,7 @@ class SalesOrderList(SalesOrderMixin, APIDownloadMixin, ListCreateAPI): 'project_code': ['project_code__code'], } - filterset_fields = [ - 'customer', - ] + filterset_fields = ['customer'] ordering_fields = [ 'creation_date', @@ -715,6 +730,7 @@ class SalesOrderList(SalesOrderMixin, APIDownloadMixin, ListCreateAPI): class SalesOrderDetail(SalesOrderMixin, RetrieveUpdateDestroyAPI): """API endpoint for detail view of a SalesOrder object.""" + pass @@ -723,12 +739,10 @@ class SalesOrderLineItemFilter(LineItemFilter): class Meta: """Metaclass options.""" + price_field = 'sale_price' model = models.SalesOrderLineItem - fields = [ - 'order', - 'part', - ] + fields = ['order', 'part'] completed = rest_filters.BooleanFilter(label='completed', method='filter_completed') @@ -801,22 +815,14 @@ class SalesOrderLineItemList(SalesOrderLineItemMixin, APIDownloadMixin, ListCrea filter_backends = SEARCH_ORDER_FILTER - ordering_fields = [ - 'part__name', - 'quantity', - 'reference', - 'target_date', - ] + ordering_fields = ['part__name', 'quantity', 'reference', 'target_date'] - search_fields = [ - 'part__name', - 'quantity', - 'reference', - ] + search_fields = ['part__name', 'quantity', 'reference'] class SalesOrderLineItemDetail(SalesOrderLineItemMixin, RetrieveUpdateDestroyAPI): """API endpoint for detail view of a SalesOrderLineItem object.""" + pass @@ -863,11 +869,13 @@ class SalesOrderContextMixin: class SalesOrderCancel(SalesOrderContextMixin, CreateAPI): """API endpoint to cancel a SalesOrder""" + serializer_class = serializers.SalesOrderCancelSerializer class SalesOrderIssue(SalesOrderContextMixin, CreateAPI): """API endpoint to issue a SalesOrder""" + serializer_class = serializers.SalesOrderIssueSerializer @@ -967,14 +975,12 @@ class SalesOrderAllocationList(ListAPI): else: queryset = queryset.exclude( line__order__status__in=SalesOrderStatusGroups.OPEN, - shipment__shipment_date=None + shipment__shipment_date=None, ) return queryset - filter_backends = [ - rest_filters.DjangoFilterBackend, - ] + filter_backends = [rest_filters.DjangoFilterBackend] class SalesOrderShipmentFilter(rest_filters.FilterSet): @@ -984,9 +990,7 @@ class SalesOrderShipmentFilter(rest_filters.FilterSet): """Metaclass options.""" model = models.SalesOrderShipment - fields = [ - 'order', - ] + fields = ['order'] shipped = rest_filters.BooleanFilter(label='shipped', method='filter_shipped') @@ -1012,9 +1016,7 @@ class SalesOrderShipmentList(ListCreateAPI): serializer_class = serializers.SalesOrderShipmentSerializer filterset_class = SalesOrderShipmentFilter - filter_backends = [ - rest_filters.DjangoFilterBackend, - ] + filter_backends = [rest_filters.DjangoFilterBackend] class SalesOrderShipmentDetail(RetrieveUpdateDestroyAPI): @@ -1051,9 +1053,7 @@ class PurchaseOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView): queryset = models.PurchaseOrderAttachment.objects.all() serializer_class = serializers.PurchaseOrderAttachmentSerializer - filterset_fields = [ - 'order', - ] + filterset_fields = ['order'] class PurchaseOrderAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI): @@ -1070,9 +1070,7 @@ class ReturnOrderFilter(OrderFilter): """Metaclass options""" model = models.ReturnOrder - fields = [ - 'customer', - ] + fields = ['customer'] class ReturnOrderMixin: @@ -1099,9 +1097,7 @@ class ReturnOrderMixin: """Return annotated queryset for this endpoint""" queryset = super().get_queryset(*args, **kwargs) - queryset = queryset.prefetch_related( - 'customer', - ) + queryset = queryset.prefetch_related('customer') queryset = serializers.ReturnOrderSerializer.annotate_queryset(queryset) @@ -1123,7 +1119,9 @@ class ReturnOrderList(ReturnOrderMixin, APIDownloadMixin, ListCreateAPI): item.save() headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + return Response( + serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) def download_queryset(self, queryset, export_format): """Download this queryset as a file""" @@ -1164,6 +1162,7 @@ class ReturnOrderList(ReturnOrderMixin, APIDownloadMixin, ListCreateAPI): class ReturnOrderDetail(ReturnOrderMixin, RetrieveUpdateDestroyAPI): """API endpoint for detail view of a single ReturnOrder object""" + pass @@ -1178,7 +1177,9 @@ class ReturnOrderContextMixin: # Pass the ReturnOrder instance through to the serializer for validation try: - context['order'] = models.ReturnOrder.objects.get(pk=self.kwargs.get('pk', None)) + context['order'] = models.ReturnOrder.objects.get( + pk=self.kwargs.get('pk', None) + ) except Exception: pass @@ -1189,16 +1190,19 @@ class ReturnOrderContextMixin: class ReturnOrderCancel(ReturnOrderContextMixin, CreateAPI): """API endpoint to cancel a ReturnOrder""" + serializer_class = serializers.ReturnOrderCancelSerializer class ReturnOrderComplete(ReturnOrderContextMixin, CreateAPI): """API endpoint to complete a ReturnOrder""" + serializer_class = serializers.ReturnOrderCompleteSerializer class ReturnOrderIssue(ReturnOrderContextMixin, CreateAPI): """API endpoint to issue (place) a ReturnOrder""" + serializer_class = serializers.ReturnOrderIssueSerializer @@ -1214,12 +1218,10 @@ class ReturnOrderLineItemFilter(LineItemFilter): class Meta: """Metaclass options""" + price_field = 'price' model = models.ReturnOrderLineItem - fields = [ - 'order', - 'item', - ] + fields = ['order', 'item'] outcome = rest_filters.NumberFilter(label='outcome') @@ -1257,31 +1259,27 @@ class ReturnOrderLineItemMixin: """Return annotated queryset for this endpoint""" queryset = super().get_queryset(*args, **kwargs) - queryset = queryset.prefetch_related( - 'order', - 'item', - 'item__part', - ) + queryset = queryset.prefetch_related('order', 'item', 'item__part') return queryset -class ReturnOrderLineItemList(ReturnOrderLineItemMixin, APIDownloadMixin, ListCreateAPI): +class ReturnOrderLineItemList( + ReturnOrderLineItemMixin, APIDownloadMixin, ListCreateAPI +): """API endpoint for accessing a list of ReturnOrderLineItemList objects""" filterset_class = ReturnOrderLineItemFilter def download_queryset(self, queryset, export_format): """Download the requested queryset as a file""" - raise NotImplementedError("download_queryset not yet implemented for this endpoint") + raise NotImplementedError( + "download_queryset not yet implemented for this endpoint" + ) filter_backends = SEARCH_ORDER_FILTER - ordering_fields = [ - 'reference', - 'target_date', - 'received_date', - ] + ordering_fields = ['reference', 'target_date', 'received_date'] search_fields = [ 'item_serial', @@ -1293,6 +1291,7 @@ class ReturnOrderLineItemList(ReturnOrderLineItemMixin, APIDownloadMixin, ListCr class ReturnOrderLineItemDetail(ReturnOrderLineItemMixin, RetrieveUpdateDestroyAPI): """API endpoint for detail view of a ReturnOrderLineItem object""" + pass @@ -1320,9 +1319,7 @@ class ReturnOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView): queryset = models.ReturnOrderAttachment.objects.all() serializer_class = serializers.ReturnOrderAttachmentSerializer - filterset_fields = [ - 'order', - ] + filterset_fields = ['order'] class ReturnOrderAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI): @@ -1385,7 +1382,9 @@ class OrderCalendarExport(ICalFeed): # Still nothing - return Unauth. header with info on how to authenticate # Information is needed by client, eg Thunderbird - response = JsonResponse({"detail": "Authentication credentials were not provided."}) + response = JsonResponse({ + "detail": "Authentication credentials were not provided." + }) response['WWW-Authenticate'] = 'Basic realm="api"' response.status_code = 401 return response @@ -1429,21 +1428,27 @@ class OrderCalendarExport(ICalFeed): if obj['include_completed'] is False: # Do not include completed orders from list in this case # Completed status = 30 - outlist = models.PurchaseOrder.objects.filter(target_date__isnull=False).filter(status__lt=PurchaseOrderStatus.COMPLETE.value) + outlist = models.PurchaseOrder.objects.filter( + target_date__isnull=False + ).filter(status__lt=PurchaseOrderStatus.COMPLETE.value) else: outlist = models.PurchaseOrder.objects.filter(target_date__isnull=False) elif obj["ordertype"] == 'sales-order': if obj['include_completed'] is False: # Do not include completed (=shipped) orders from list in this case # Shipped status = 20 - outlist = models.SalesOrder.objects.filter(target_date__isnull=False).filter(status__lt=SalesOrderStatus.SHIPPED.value) + outlist = models.SalesOrder.objects.filter( + target_date__isnull=False + ).filter(status__lt=SalesOrderStatus.SHIPPED.value) else: outlist = models.SalesOrder.objects.filter(target_date__isnull=False) elif obj["ordertype"] == 'return-order': if obj['include_completed'] is False: # Do not include completed orders from list in this case # Complete status = 30 - outlist = models.ReturnOrder.objects.filter(target_date__isnull=False).filter(status__lt=ReturnOrderStatus.COMPLETE.value) + outlist = models.ReturnOrder.objects.filter( + target_date__isnull=False + ).filter(status__lt=ReturnOrderStatus.COMPLETE.value) else: outlist = models.ReturnOrder.objects.filter(target_date__isnull=False) else: @@ -1485,160 +1490,405 @@ class OrderCalendarExport(ICalFeed): order_api_urls = [ - # API endpoints for purchase orders - re_path(r'^po/', include([ - - # Purchase order attachments - re_path(r'attachment/', include([ - path('/', PurchaseOrderAttachmentDetail.as_view(), name='api-po-attachment-detail'), - re_path(r'^.*$', PurchaseOrderAttachmentList.as_view(), name='api-po-attachment-list'), - ])), - - # Individual purchase order detail URLs - path(r'/', include([ - re_path(r'^cancel/', PurchaseOrderCancel.as_view(), name='api-po-cancel'), - re_path(r'^complete/', PurchaseOrderComplete.as_view(), name='api-po-complete'), - re_path(r'^issue/', PurchaseOrderIssue.as_view(), name='api-po-issue'), - re_path(r'^metadata/', MetadataView.as_view(), {'model': models.PurchaseOrder}, name='api-po-metadata'), - re_path(r'^receive/', PurchaseOrderReceive.as_view(), name='api-po-receive'), - - # PurchaseOrder detail API endpoint - re_path(r'.*$', PurchaseOrderDetail.as_view(), name='api-po-detail'), - ])), - - # Purchase order status code information - re_path(r'status/', StatusView.as_view(), {StatusView.MODEL_REF: PurchaseOrderStatus}, name='api-po-status-codes'), - - # Purchase order list - re_path(r'^.*$', PurchaseOrderList.as_view(), name='api-po-list'), - ])), - + re_path( + r'^po/', + include([ + # Purchase order attachments + re_path( + r'attachment/', + include([ + path( + '/', + PurchaseOrderAttachmentDetail.as_view(), + name='api-po-attachment-detail', + ), + re_path( + r'^.*$', + PurchaseOrderAttachmentList.as_view(), + name='api-po-attachment-list', + ), + ]), + ), + # Individual purchase order detail URLs + path( + r'/', + include([ + re_path( + r'^cancel/', PurchaseOrderCancel.as_view(), name='api-po-cancel' + ), + re_path( + r'^complete/', + PurchaseOrderComplete.as_view(), + name='api-po-complete', + ), + re_path( + r'^issue/', PurchaseOrderIssue.as_view(), name='api-po-issue' + ), + re_path( + r'^metadata/', + MetadataView.as_view(), + {'model': models.PurchaseOrder}, + name='api-po-metadata', + ), + re_path( + r'^receive/', + PurchaseOrderReceive.as_view(), + name='api-po-receive', + ), + # PurchaseOrder detail API endpoint + re_path( + r'.*$', PurchaseOrderDetail.as_view(), name='api-po-detail' + ), + ]), + ), + # Purchase order status code information + re_path( + r'status/', + StatusView.as_view(), + {StatusView.MODEL_REF: PurchaseOrderStatus}, + name='api-po-status-codes', + ), + # Purchase order list + re_path(r'^.*$', PurchaseOrderList.as_view(), name='api-po-list'), + ]), + ), # API endpoints for purchase order line items - re_path(r'^po-line/', include([ - path('/', include([ - re_path(r'^metadata/', MetadataView.as_view(), {'model': models.PurchaseOrderLineItem}, name='api-po-line-metadata'), - re_path(r'^.*$', PurchaseOrderLineItemDetail.as_view(), name='api-po-line-detail'), - ])), - re_path(r'^.*$', PurchaseOrderLineItemList.as_view(), name='api-po-line-list'), - ])), - + re_path( + r'^po-line/', + include([ + path( + '/', + include([ + re_path( + r'^metadata/', + MetadataView.as_view(), + {'model': models.PurchaseOrderLineItem}, + name='api-po-line-metadata', + ), + re_path( + r'^.*$', + PurchaseOrderLineItemDetail.as_view(), + name='api-po-line-detail', + ), + ]), + ), + re_path( + r'^.*$', PurchaseOrderLineItemList.as_view(), name='api-po-line-list' + ), + ]), + ), # API endpoints for purchase order extra line - re_path(r'^po-extra-line/', include([ - path('/', include([ - re_path(r'^metadata/', MetadataView.as_view(), {'model': models.PurchaseOrderExtraLine}, name='api-po-extra-line-metadata'), - re_path(r'^.*$', PurchaseOrderExtraLineDetail.as_view(), name='api-po-extra-line-detail'), - ])), - path('', PurchaseOrderExtraLineList.as_view(), name='api-po-extra-line-list'), - ])), - + re_path( + r'^po-extra-line/', + include([ + path( + '/', + include([ + re_path( + r'^metadata/', + MetadataView.as_view(), + {'model': models.PurchaseOrderExtraLine}, + name='api-po-extra-line-metadata', + ), + re_path( + r'^.*$', + PurchaseOrderExtraLineDetail.as_view(), + name='api-po-extra-line-detail', + ), + ]), + ), + path( + '', PurchaseOrderExtraLineList.as_view(), name='api-po-extra-line-list' + ), + ]), + ), # API endpoints for sales ordesr - re_path(r'^so/', include([ - re_path(r'attachment/', include([ - path('/', SalesOrderAttachmentDetail.as_view(), name='api-so-attachment-detail'), - re_path(r'^.*$', SalesOrderAttachmentList.as_view(), name='api-so-attachment-list'), - ])), - - re_path(r'^shipment/', include([ - path(r'/', include([ - path('ship/', SalesOrderShipmentComplete.as_view(), name='api-so-shipment-ship'), - re_path(r'^metadata/', MetadataView.as_view(), {'model': models.SalesOrderShipment}, name='api-so-shipment-metadata'), - re_path(r'^.*$', SalesOrderShipmentDetail.as_view(), name='api-so-shipment-detail'), - ])), - re_path(r'^.*$', SalesOrderShipmentList.as_view(), name='api-so-shipment-list'), - ])), - - # Sales order detail view - path(r'/', include([ - re_path(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'), - re_path(r'^allocate-serials/', SalesOrderAllocateSerials.as_view(), name='api-so-allocate-serials'), - re_path(r'^cancel/', SalesOrderCancel.as_view(), name='api-so-cancel'), - re_path(r'^issue/', SalesOrderIssue.as_view(), name='api-so-issue'), - re_path(r'^complete/', SalesOrderComplete.as_view(), name='api-so-complete'), - re_path(r'^metadata/', MetadataView.as_view(), {'model': models.SalesOrder}, name='api-so-metadata'), - - # SalesOrder detail endpoint - re_path(r'^.*$', SalesOrderDetail.as_view(), name='api-so-detail'), - ])), - - # Sales order status code information - re_path(r'status/', StatusView.as_view(), {StatusView.MODEL_REF: SalesOrderStatus}, name='api-so-status-codes'), - - # Sales order list view - re_path(r'^.*$', SalesOrderList.as_view(), name='api-so-list'), - ])), - + re_path( + r'^so/', + include([ + re_path( + r'attachment/', + include([ + path( + '/', + SalesOrderAttachmentDetail.as_view(), + name='api-so-attachment-detail', + ), + re_path( + r'^.*$', + SalesOrderAttachmentList.as_view(), + name='api-so-attachment-list', + ), + ]), + ), + re_path( + r'^shipment/', + include([ + path( + r'/', + include([ + path( + 'ship/', + SalesOrderShipmentComplete.as_view(), + name='api-so-shipment-ship', + ), + re_path( + r'^metadata/', + MetadataView.as_view(), + {'model': models.SalesOrderShipment}, + name='api-so-shipment-metadata', + ), + re_path( + r'^.*$', + SalesOrderShipmentDetail.as_view(), + name='api-so-shipment-detail', + ), + ]), + ), + re_path( + r'^.*$', + SalesOrderShipmentList.as_view(), + name='api-so-shipment-list', + ), + ]), + ), + # Sales order detail view + path( + r'/', + include([ + re_path( + r'^allocate/', + SalesOrderAllocate.as_view(), + name='api-so-allocate', + ), + re_path( + r'^allocate-serials/', + SalesOrderAllocateSerials.as_view(), + name='api-so-allocate-serials', + ), + re_path( + r'^cancel/', SalesOrderCancel.as_view(), name='api-so-cancel' + ), + re_path(r'^issue/', SalesOrderIssue.as_view(), name='api-so-issue'), + re_path( + r'^complete/', + SalesOrderComplete.as_view(), + name='api-so-complete', + ), + re_path( + r'^metadata/', + MetadataView.as_view(), + {'model': models.SalesOrder}, + name='api-so-metadata', + ), + # SalesOrder detail endpoint + re_path(r'^.*$', SalesOrderDetail.as_view(), name='api-so-detail'), + ]), + ), + # Sales order status code information + re_path( + r'status/', + StatusView.as_view(), + {StatusView.MODEL_REF: SalesOrderStatus}, + name='api-so-status-codes', + ), + # Sales order list view + re_path(r'^.*$', SalesOrderList.as_view(), name='api-so-list'), + ]), + ), # API endpoints for sales order line items - re_path(r'^so-line/', include([ - path('/', include([ - re_path(r'^metadata/', MetadataView.as_view(), {'model': models.SalesOrderLineItem}, name='api-so-line-metadata'), - re_path(r'^.*$', SalesOrderLineItemDetail.as_view(), name='api-so-line-detail'), - ])), - path('', SalesOrderLineItemList.as_view(), name='api-so-line-list'), - ])), - + re_path( + r'^so-line/', + include([ + path( + '/', + include([ + re_path( + r'^metadata/', + MetadataView.as_view(), + {'model': models.SalesOrderLineItem}, + name='api-so-line-metadata', + ), + re_path( + r'^.*$', + SalesOrderLineItemDetail.as_view(), + name='api-so-line-detail', + ), + ]), + ), + path('', SalesOrderLineItemList.as_view(), name='api-so-line-list'), + ]), + ), # API endpoints for sales order extra line - re_path(r'^so-extra-line/', include([ - path('/', include([ - re_path(r'^metadata/', MetadataView.as_view(), {'model': models.SalesOrderExtraLine}, name='api-so-extra-line-metadata'), - re_path(r'^.*$', SalesOrderExtraLineDetail.as_view(), name='api-so-extra-line-detail'), - ])), - path('', SalesOrderExtraLineList.as_view(), name='api-so-extra-line-list'), - ])), - + re_path( + r'^so-extra-line/', + include([ + path( + '/', + include([ + re_path( + r'^metadata/', + MetadataView.as_view(), + {'model': models.SalesOrderExtraLine}, + name='api-so-extra-line-metadata', + ), + re_path( + r'^.*$', + SalesOrderExtraLineDetail.as_view(), + name='api-so-extra-line-detail', + ), + ]), + ), + path('', SalesOrderExtraLineList.as_view(), name='api-so-extra-line-list'), + ]), + ), # API endpoints for sales order allocations - re_path(r'^so-allocation/', include([ - path('/', SalesOrderAllocationDetail.as_view(), name='api-so-allocation-detail'), - re_path(r'^.*$', SalesOrderAllocationList.as_view(), name='api-so-allocation-list'), - ])), - + re_path( + r'^so-allocation/', + include([ + path( + '/', + SalesOrderAllocationDetail.as_view(), + name='api-so-allocation-detail', + ), + re_path( + r'^.*$', + SalesOrderAllocationList.as_view(), + name='api-so-allocation-list', + ), + ]), + ), # API endpoints for return orders - re_path(r'^ro/', include([ - - re_path(r'^attachment/', include([ - path('/', ReturnOrderAttachmentDetail.as_view(), name='api-return-order-attachment-detail'), - re_path(r'^.*$', ReturnOrderAttachmentList.as_view(), name='api-return-order-attachment-list'), - ])), - - # Return Order detail endpoints - path('/', include([ - re_path(r'cancel/', ReturnOrderCancel.as_view(), name='api-return-order-cancel'), - re_path(r'complete/', ReturnOrderComplete.as_view(), name='api-return-order-complete'), - re_path(r'issue/', ReturnOrderIssue.as_view(), name='api-return-order-issue'), - re_path(r'receive/', ReturnOrderReceive.as_view(), name='api-return-order-receive'), - re_path(r'metadata/', MetadataView.as_view(), {'model': models.ReturnOrder}, name='api-return-order-metadata'), - re_path(r'.*$', ReturnOrderDetail.as_view(), name='api-return-order-detail'), - ])), - - # Return order status code information - re_path(r'status/', StatusView.as_view(), {StatusView.MODEL_REF: ReturnOrderStatus}, name='api-return-order-status-codes'), - - # Return Order list - re_path(r'^.*$', ReturnOrderList.as_view(), name='api-return-order-list'), - ])), - + re_path( + r'^ro/', + include([ + re_path( + r'^attachment/', + include([ + path( + '/', + ReturnOrderAttachmentDetail.as_view(), + name='api-return-order-attachment-detail', + ), + re_path( + r'^.*$', + ReturnOrderAttachmentList.as_view(), + name='api-return-order-attachment-list', + ), + ]), + ), + # Return Order detail endpoints + path( + '/', + include([ + re_path( + r'cancel/', + ReturnOrderCancel.as_view(), + name='api-return-order-cancel', + ), + re_path( + r'complete/', + ReturnOrderComplete.as_view(), + name='api-return-order-complete', + ), + re_path( + r'issue/', + ReturnOrderIssue.as_view(), + name='api-return-order-issue', + ), + re_path( + r'receive/', + ReturnOrderReceive.as_view(), + name='api-return-order-receive', + ), + re_path( + r'metadata/', + MetadataView.as_view(), + {'model': models.ReturnOrder}, + name='api-return-order-metadata', + ), + re_path( + r'.*$', + ReturnOrderDetail.as_view(), + name='api-return-order-detail', + ), + ]), + ), + # Return order status code information + re_path( + r'status/', + StatusView.as_view(), + {StatusView.MODEL_REF: ReturnOrderStatus}, + name='api-return-order-status-codes', + ), + # Return Order list + re_path(r'^.*$', ReturnOrderList.as_view(), name='api-return-order-list'), + ]), + ), # API endpoints for return order lines - re_path(r'^ro-line/', include([ - path('/', include([ - re_path(r'^metadata/', MetadataView.as_view(), {'model': models.ReturnOrderLineItem}, name='api-return-order-line-metadata'), - re_path(r'^.*$', ReturnOrderLineItemDetail.as_view(), name='api-return-order-line-detail'), - ])), - - # Return order line item status code information - re_path(r'status/', StatusView.as_view(), {StatusView.MODEL_REF: ReturnOrderLineStatus}, name='api-return-order-line-status-codes'), - - path('', ReturnOrderLineItemList.as_view(), name='api-return-order-line-list'), - ])), - + re_path( + r'^ro-line/', + include([ + path( + '/', + include([ + re_path( + r'^metadata/', + MetadataView.as_view(), + {'model': models.ReturnOrderLineItem}, + name='api-return-order-line-metadata', + ), + re_path( + r'^.*$', + ReturnOrderLineItemDetail.as_view(), + name='api-return-order-line-detail', + ), + ]), + ), + # Return order line item status code information + re_path( + r'status/', + StatusView.as_view(), + {StatusView.MODEL_REF: ReturnOrderLineStatus}, + name='api-return-order-line-status-codes', + ), + path( + '', ReturnOrderLineItemList.as_view(), name='api-return-order-line-list' + ), + ]), + ), # API endpoints for return order extra line - re_path(r'^ro-extra-line/', include([ - path('/', include([ - re_path(r'^metadata/', MetadataView.as_view(), {'model': models.ReturnOrderExtraLine}, name='api-return-order-extra-line-metadata'), - re_path(r'^.*$', ReturnOrderExtraLineDetail.as_view(), name='api-return-order-extra-line-detail'), - ])), - path('', ReturnOrderExtraLineList.as_view(), name='api-return-order-extra-line-list'), - ])), - + re_path( + r'^ro-extra-line/', + include([ + path( + '/', + include([ + re_path( + r'^metadata/', + MetadataView.as_view(), + {'model': models.ReturnOrderExtraLine}, + name='api-return-order-extra-line-metadata', + ), + re_path( + r'^.*$', + ReturnOrderExtraLineDetail.as_view(), + name='api-return-order-extra-line-detail', + ), + ]), + ), + path( + '', + ReturnOrderExtraLineList.as_view(), + name='api-return-order-extra-line-list', + ), + ]), + ), # API endpoint for subscribing to ICS calendar of purchase/sales/return orders - re_path(r'^calendar/(?Ppurchase-order|sales-order|return-order)/calendar.ics', OrderCalendarExport(), name='api-po-so-calendar'), + re_path( + r'^calendar/(?Ppurchase-order|sales-order|return-order)/calendar.ics', + OrderCalendarExport(), + name='api-po-so-calendar', + ), ] diff --git a/InvenTree/order/apps.py b/InvenTree/order/apps.py index e3bc0fc6d2..65a86fdb04 100644 --- a/InvenTree/order/apps.py +++ b/InvenTree/order/apps.py @@ -5,4 +5,5 @@ from django.apps import AppConfig class OrderConfig(AppConfig): """Configuration class for the 'order' app""" + name = 'order' diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py index c38f50ed56..144057818a 100644 --- a/InvenTree/order/forms.py +++ b/InvenTree/order/forms.py @@ -17,14 +17,16 @@ class OrderMatchItemForm(MatchItemForm): if 'quantity' in col_guess.lower(): return forms.CharField( required=False, - widget=forms.NumberInput(attrs={ - 'name': 'quantity' + str(row['index']), - 'class': 'numberinput', - 'type': 'number', - 'min': '0', - 'step': 'any', - 'value': clean_decimal(row.get('quantity', '')), - }) + widget=forms.NumberInput( + attrs={ + 'name': 'quantity' + str(row['index']), + 'class': 'numberinput', + 'type': 'number', + 'min': '0', + 'step': 'any', + 'value': clean_decimal(row.get('quantity', '')), + } + ), ) # set price field elif 'price' in col_guess.lower(): diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 20f7432fab..940e6ec08e 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -35,19 +35,31 @@ from common.settings import currency_code_default from company.models import Address, Company, Contact, SupplierPart from generic.states import StateTransitionMixin from InvenTree.exceptions import log_error -from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeURLField, - RoundingDecimalField) +from InvenTree.fields import ( + InvenTreeModelMoneyField, + InvenTreeURLField, + RoundingDecimalField, +) from InvenTree.helpers import decimal2string from InvenTree.helpers_model import getSetting, notify_responsible -from InvenTree.models import (InvenTreeAttachment, InvenTreeBarcodeMixin, - InvenTreeNotesMixin, MetadataMixin, - ReferenceIndexingMixin) -from InvenTree.status_codes import (PurchaseOrderStatus, - PurchaseOrderStatusGroups, - ReturnOrderLineStatus, ReturnOrderStatus, - ReturnOrderStatusGroups, SalesOrderStatus, - SalesOrderStatusGroups, StockHistoryCode, - StockStatus) +from InvenTree.models import ( + InvenTreeAttachment, + InvenTreeBarcodeMixin, + InvenTreeNotesMixin, + MetadataMixin, + ReferenceIndexingMixin, +) +from InvenTree.status_codes import ( + PurchaseOrderStatus, + PurchaseOrderStatusGroups, + ReturnOrderLineStatus, + ReturnOrderStatus, + ReturnOrderStatusGroups, + SalesOrderStatus, + SalesOrderStatusGroups, + StockHistoryCode, + StockStatus, +) from part import models as PartModels from plugin.events import trigger_event @@ -59,6 +71,7 @@ class TotalPriceMixin(models.Model): class Meta: """Meta for MetadataMixin.""" + abstract = True def save(self, *args, **kwargs): @@ -68,18 +81,20 @@ class TotalPriceMixin(models.Model): super().save(*args, **kwargs) total_price = InvenTreeModelMoneyField( - null=True, blank=True, + null=True, + blank=True, allow_negative=False, verbose_name=_('Total Price'), - help_text=_('Total price for this order') + help_text=_('Total price for this order'), ) order_currency = models.CharField( max_length=3, verbose_name=_('Order Currency'), - blank=True, null=True, + blank=True, + null=True, help_text=_('Currency for this order (leave blank to use company default)'), - validators=[InvenTree.validators.validate_currency_code] + validators=[InvenTree.validators.validate_currency_code], ) @property @@ -122,7 +137,6 @@ class TotalPriceMixin(models.Model): # order items for line in self.lines.all(): - if not line.price: continue @@ -140,7 +154,6 @@ class TotalPriceMixin(models.Model): # extra items for line in self.extra_lines.all(): - if not line.price: continue @@ -161,7 +174,13 @@ class TotalPriceMixin(models.Model): return total -class Order(StateTransitionMixin, InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, ReferenceIndexingMixin): +class Order( + StateTransitionMixin, + InvenTreeBarcodeMixin, + InvenTreeNotesMixin, + MetadataMixin, + ReferenceIndexingMixin, +): """Abstract model for an order. Instances of this class: @@ -182,6 +201,7 @@ class Order(StateTransitionMixin, InvenTreeBarcodeMixin, InvenTreeNotesMixin, Me class Meta: """Metaclass options. Abstract ensures no database table is created.""" + abstract = True def save(self, *args, **kwargs): @@ -214,7 +234,11 @@ class Order(StateTransitionMixin, InvenTreeBarcodeMixin, InvenTreeNotesMixin, Me It requires any subclasses to implement the get_status_class() class method """ today = datetime.now().date() - return Q(status__in=cls.get_status_class().OPEN) & ~Q(target_date=None) & Q(target_date__lt=today) + return ( + Q(status__in=cls.get_status_class().OPEN) + & ~Q(target_date=None) + & Q(target_date__lt=today) + ) @property def is_overdue(self): @@ -222,37 +246,59 @@ class Order(StateTransitionMixin, InvenTreeBarcodeMixin, InvenTreeNotesMixin, Me Makes use of the overdue_filter() method to avoid code duplication """ - return self.__class__.objects.filter(pk=self.pk).filter(self.__class__.overdue_filter()).exists() + return ( + self.__class__.objects.filter(pk=self.pk) + .filter(self.__class__.overdue_filter()) + .exists() + ) - description = models.CharField(max_length=250, blank=True, verbose_name=_('Description'), help_text=_('Order description (optional)')) + description = models.CharField( + max_length=250, + blank=True, + verbose_name=_('Description'), + help_text=_('Order description (optional)'), + ) project_code = models.ForeignKey( - common_models.ProjectCode, on_delete=models.SET_NULL, - blank=True, null=True, - verbose_name=_('Project Code'), help_text=_('Select project code for this order') + common_models.ProjectCode, + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_('Project Code'), + help_text=_('Select project code for this order'), ) - link = InvenTreeURLField(blank=True, verbose_name=_('Link'), help_text=_('Link to external page')) + link = InvenTreeURLField( + blank=True, verbose_name=_('Link'), help_text=_('Link to external page') + ) target_date = models.DateField( - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Target Date'), - help_text=_('Expected date for order delivery. Order will be overdue after this date.'), + help_text=_( + 'Expected date for order delivery. Order will be overdue after this date.' + ), ) - creation_date = models.DateField(blank=True, null=True, verbose_name=_('Creation Date')) + creation_date = models.DateField( + blank=True, null=True, verbose_name=_('Creation Date') + ) - created_by = models.ForeignKey(User, - on_delete=models.SET_NULL, - blank=True, null=True, - related_name='+', - verbose_name=_('Created By') - ) + created_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name='+', + verbose_name=_('Created By'), + ) responsible = models.ForeignKey( UserModels.Owner, on_delete=models.SET_NULL, - blank=True, null=True, + blank=True, + null=True, help_text=_('User or group responsible for this order'), verbose_name=_('Responsible'), related_name='+', @@ -261,7 +307,8 @@ class Order(StateTransitionMixin, InvenTreeBarcodeMixin, InvenTreeNotesMixin, Me contact = models.ForeignKey( Contact, on_delete=models.SET_NULL, - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Contact'), help_text=_('Point of contact for this order'), related_name='+', @@ -270,7 +317,8 @@ class Order(StateTransitionMixin, InvenTreeBarcodeMixin, InvenTreeNotesMixin, Me address = models.ForeignKey( Address, on_delete=models.SET_NULL, - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Address'), help_text=_('Company address for this order'), related_name='+', @@ -310,7 +358,7 @@ class PurchaseOrder(TotalPriceMixin, Order): def api_defaults(cls, request): """Return default values for this model when issuing an API OPTIONS request""" defaults = { - 'reference': order.validators.generate_next_purchase_order_reference(), + 'reference': order.validators.generate_next_purchase_order_reference() } return defaults @@ -342,10 +390,19 @@ class PurchaseOrder(TotalPriceMixin, Order): return queryset # Construct a queryset for "received" orders within the range - received = Q(status=PurchaseOrderStatus.COMPLETE.value) & Q(complete_date__gte=min_date) & Q(complete_date__lte=max_date) + received = ( + Q(status=PurchaseOrderStatus.COMPLETE.value) + & Q(complete_date__gte=min_date) + & Q(complete_date__lte=max_date) + ) # Construct a queryset for "pending" orders within the range - pending = Q(status__in=PurchaseOrderStatusGroups.OPEN) & ~Q(target_date=None) & Q(target_date__gte=min_date) & Q(target_date__lte=max_date) + pending = ( + Q(status__in=PurchaseOrderStatusGroups.OPEN) + & ~Q(target_date=None) + & Q(target_date__gte=min_date) + & Q(target_date__lte=max_date) + ) # TODO - Construct a queryset for "overdue" orders within the range @@ -364,13 +421,14 @@ class PurchaseOrder(TotalPriceMixin, Order): verbose_name=_('Reference'), help_text=_('Order reference'), default=order.validators.generate_next_purchase_order_reference, - validators=[ - order.validators.validate_purchase_order_reference, - ] + validators=[order.validators.validate_purchase_order_reference], ) - status = models.PositiveIntegerField(default=PurchaseOrderStatus.PENDING.value, choices=PurchaseOrderStatus.items(), - help_text=_('Purchase order status')) + status = models.PositiveIntegerField( + default=PurchaseOrderStatus.PENDING.value, + choices=PurchaseOrderStatus.items(), + help_text=_('Purchase order status'), + ) @property def status_text(self): @@ -378,14 +436,13 @@ class PurchaseOrder(TotalPriceMixin, Order): return PurchaseOrderStatus.text(self.status) supplier = models.ForeignKey( - Company, on_delete=models.SET_NULL, + Company, + on_delete=models.SET_NULL, null=True, - limit_choices_to={ - 'is_supplier': True, - }, + limit_choices_to={'is_supplier': True}, related_name='purchase_orders', verbose_name=_('Supplier'), - help_text=_('Company from which the items are being ordered') + help_text=_('Company from which the items are being ordered'), ) @property @@ -393,30 +450,45 @@ class PurchaseOrder(TotalPriceMixin, Order): """Accessor helper for Order base class""" return self.supplier - supplier_reference = models.CharField(max_length=64, blank=True, verbose_name=_('Supplier Reference'), help_text=_("Supplier order reference code")) + supplier_reference = models.CharField( + max_length=64, + blank=True, + verbose_name=_('Supplier Reference'), + help_text=_("Supplier order reference code"), + ) received_by = models.ForeignKey( User, on_delete=models.SET_NULL, - blank=True, null=True, + blank=True, + null=True, related_name='+', - verbose_name=_('received by') + verbose_name=_('received by'), ) issue_date = models.DateField( - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Issue Date'), - help_text=_('Date order was issued') + help_text=_('Date order was issued'), ) complete_date = models.DateField( - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Completion Date'), - help_text=_('Date order was completed') + help_text=_('Date order was completed'), ) @transaction.atomic - def add_line_item(self, supplier_part, quantity, group: bool = True, reference: str = '', purchase_price=None): + def add_line_item( + self, + supplier_part, + quantity, + group: bool = True, + reference: str = '', + purchase_price=None, + ): """Add a new line item to this purchase order. This function will check that: @@ -442,12 +514,15 @@ class PurchaseOrder(TotalPriceMixin, Order): quantity = int(quantity) if quantity <= 0: raise ValidationError({ - 'quantity': _("Quantity must be greater than zero")}) + 'quantity': _("Quantity must be greater than zero") + }) except ValueError: raise ValidationError({'quantity': _("Invalid quantity provided")}) if supplier_part.supplier != self.supplier: - raise ValidationError({'supplier': _("Part supplier must match PO supplier")}) + raise ValidationError({ + 'supplier': _("Part supplier must match PO supplier") + }) if group: # Check if there is already a matching line item (for this PurchaseOrder) @@ -498,7 +573,7 @@ class PurchaseOrder(TotalPriceMixin, Order): self, PurchaseOrder, exclude=self.created_by, - content=InvenTreeNotificationBodies.NewOrder + content=InvenTreeNotificationBodies.NewOrder, ) def _action_complete(self, *args, **kwargs): @@ -522,17 +597,23 @@ class PurchaseOrder(TotalPriceMixin, Order): @transaction.atomic def place_order(self): """Attempt to transition to PLACED status.""" - return self.handle_transition(self.status, PurchaseOrderStatus.PLACED.value, self, self._action_place) + return self.handle_transition( + self.status, PurchaseOrderStatus.PLACED.value, self, self._action_place + ) @transaction.atomic def complete_order(self): """Attempt to transition to COMPLETE status.""" - return self.handle_transition(self.status, PurchaseOrderStatus.COMPLETE.value, self, self._action_complete) + return self.handle_transition( + self.status, PurchaseOrderStatus.COMPLETE.value, self, self._action_complete + ) @transaction.atomic def cancel_order(self): """Attempt to transition to CANCELLED status.""" - return self.handle_transition(self.status, PurchaseOrderStatus.CANCELLED.value, self, self._action_cancel) + return self.handle_transition( + self.status, PurchaseOrderStatus.CANCELLED.value, self, self._action_cancel + ) @property def is_pending(self): @@ -553,7 +634,7 @@ class PurchaseOrder(TotalPriceMixin, Order): """ return self.status in [ PurchaseOrderStatus.PLACED.value, - PurchaseOrderStatus.PENDING.value + PurchaseOrderStatus.PENDING.value, ] def _action_cancel(self, *args, **kwargs): @@ -569,8 +650,9 @@ class PurchaseOrder(TotalPriceMixin, Order): self, PurchaseOrder, exclude=self.created_by, - content=InvenTreeNotificationBodies.OrderCanceled + content=InvenTreeNotificationBodies.OrderCanceled, ) + # endregion def pending_line_items(self): @@ -605,7 +687,9 @@ class PurchaseOrder(TotalPriceMixin, Order): return self.lines.count() > 0 and self.pending_line_items().count() == 0 @transaction.atomic - def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK.value, **kwargs): + def receive_line_item( + self, line, location, quantity, user, status=StockStatus.OK.value, **kwargs + ): """Receive a line item (or partial line item) against this PurchaseOrder.""" # Extract optional batch code for the new stock item batch_code = kwargs.get('batch_code', '') @@ -635,13 +719,10 @@ class PurchaseOrder(TotalPriceMixin, Order): }) quantity = InvenTree.helpers.clean_decimal(quantity) except TypeError: - raise ValidationError({ - "quantity": _("Invalid quantity provided") - }) + raise ValidationError({"quantity": _("Invalid quantity provided")}) # Create a new stock item if line.part and quantity > 0: - # Calculate received quantity in base units stock_quantity = line.part.base_quantity(quantity) @@ -660,7 +741,6 @@ class PurchaseOrder(TotalPriceMixin, Order): serials = [None] for sn in serials: - item = stock.models.StockItem( part=line.part.part, supplier_part=line.part, @@ -670,22 +750,16 @@ class PurchaseOrder(TotalPriceMixin, Order): status=status, batch=batch_code, serial=sn, - purchase_price=unit_purchase_price + purchase_price=unit_purchase_price, ) # Assign the provided barcode if barcode: - item.assign_barcode( - barcode_data=barcode, - save=False - ) + item.assign_barcode(barcode_data=barcode, save=False) item.save(add_note=False) - tracking_info = { - 'status': status, - 'purchaseorder': self.pk, - } + tracking_info = {'status': status, 'purchaseorder': self.pk} item.add_tracking_entry( StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER, @@ -694,7 +768,7 @@ class PurchaseOrder(TotalPriceMixin, Order): deltas=tracking_info, location=location, purchaseorder=self, - quantity=quantity + quantity=quantity, ) # Update the number of parts received against the particular line item @@ -704,7 +778,6 @@ class PurchaseOrder(TotalPriceMixin, Order): # Has this order been completed? if len(self.pending_line_items()) == 0: - self.received_by = user self.complete_order() # This will save the model @@ -737,9 +810,7 @@ class SalesOrder(TotalPriceMixin, Order): @classmethod def api_defaults(cls, request): """Return default values for this model when issuing an API OPTIONS request""" - defaults = { - 'reference': order.validators.generate_next_sales_order_reference(), - } + defaults = {'reference': order.validators.generate_next_sales_order_reference()} return defaults @@ -770,10 +841,19 @@ class SalesOrder(TotalPriceMixin, Order): return queryset # Construct a queryset for "completed" orders within the range - completed = Q(status__in=SalesOrderStatusGroups.COMPLETE) & Q(shipment_date__gte=min_date) & Q(shipment_date__lte=max_date) + completed = ( + Q(status__in=SalesOrderStatusGroups.COMPLETE) + & Q(shipment_date__gte=min_date) + & Q(shipment_date__lte=max_date) + ) # Construct a queryset for "pending" orders within the range - pending = Q(status__in=SalesOrderStatusGroups.OPEN) & ~Q(target_date=None) & Q(target_date__gte=min_date) & Q(target_date__lte=max_date) + pending = ( + Q(status__in=SalesOrderStatusGroups.OPEN) + & ~Q(target_date=None) + & Q(target_date__gte=min_date) + & Q(target_date__lte=max_date) + ) # TODO: Construct a queryset for "overdue" orders within the range @@ -792,9 +872,7 @@ class SalesOrder(TotalPriceMixin, Order): verbose_name=_('Reference'), help_text=_('Order reference'), default=order.validators.generate_next_sales_order_reference, - validators=[ - order.validators.validate_sales_order_reference, - ] + validators=[order.validators.validate_sales_order_reference], ) customer = models.ForeignKey( @@ -815,7 +893,8 @@ class SalesOrder(TotalPriceMixin, Order): status = models.PositiveIntegerField( default=SalesOrderStatus.PENDING.value, choices=SalesOrderStatus.items(), - verbose_name=_('Status'), help_text=_('Purchase order status') + verbose_name=_('Status'), + help_text=_('Purchase order status'), ) @property @@ -823,16 +902,24 @@ class SalesOrder(TotalPriceMixin, Order): """Return the text representation of the status field""" return SalesOrderStatus.text(self.status) - customer_reference = models.CharField(max_length=64, blank=True, verbose_name=_('Customer Reference '), help_text=_("Customer order reference code")) + customer_reference = models.CharField( + max_length=64, + blank=True, + verbose_name=_('Customer Reference '), + help_text=_("Customer order reference code"), + ) - shipment_date = models.DateField(blank=True, null=True, verbose_name=_('Shipment Date')) + shipment_date = models.DateField( + blank=True, null=True, verbose_name=_('Shipment Date') + ) shipped_by = models.ForeignKey( User, on_delete=models.SET_NULL, - blank=True, null=True, + blank=True, + null=True, related_name='+', - verbose_name=_('shipped by') + verbose_name=_('shipped by'), ) @property @@ -870,7 +957,9 @@ class SalesOrder(TotalPriceMixin, Order): def is_completed(self): """Check if this order is "shipped" (all line items delivered).""" - return self.lines.count() > 0 and all(line.is_completed() for line in self.lines.all()) + return self.lines.count() > 0 and all( + line.is_completed() for line in self.lines.all() + ) def can_complete(self, raise_error=False, allow_incomplete_lines=False): """Test if this SalesOrder can be completed. @@ -878,23 +967,27 @@ class SalesOrder(TotalPriceMixin, Order): Throws a ValidationError if cannot be completed. """ try: - # Order without line items cannot be completed if self.lines.count() == 0: - raise ValidationError(_('Order cannot be completed as no parts have been assigned')) + raise ValidationError( + _('Order cannot be completed as no parts have been assigned') + ) # Only an open order can be marked as shipped elif not self.is_open: raise ValidationError(_('Only an open order can be marked as complete')) elif self.pending_shipment_count > 0: - raise ValidationError(_("Order cannot be completed as there are incomplete shipments")) + raise ValidationError( + _("Order cannot be completed as there are incomplete shipments") + ) elif not allow_incomplete_lines and self.pending_line_count > 0: - raise ValidationError(_("Order cannot be completed as there are incomplete line items")) + raise ValidationError( + _("Order cannot be completed as there are incomplete line items") + ) except ValidationError as e: - if raise_error: raise e else: @@ -966,7 +1059,7 @@ class SalesOrder(TotalPriceMixin, Order): self, SalesOrder, exclude=self.created_by, - content=InvenTreeNotificationBodies.OrderCanceled + content=InvenTreeNotificationBodies.OrderCanceled, ) return True @@ -974,17 +1067,29 @@ class SalesOrder(TotalPriceMixin, Order): @transaction.atomic def issue_order(self): """Attempt to transition to IN_PROGRESS status.""" - return self.handle_transition(self.status, SalesOrderStatus.IN_PROGRESS.value, self, self._action_place) + return self.handle_transition( + self.status, SalesOrderStatus.IN_PROGRESS.value, self, self._action_place + ) @transaction.atomic def complete_order(self, user, **kwargs): """Attempt to transition to SHIPPED status.""" - return self.handle_transition(self.status, SalesOrderStatus.SHIPPED.value, self, self._action_complete, user=user, **kwargs) + return self.handle_transition( + self.status, + SalesOrderStatus.SHIPPED.value, + self, + self._action_complete, + user=user, + **kwargs, + ) @transaction.atomic def cancel_order(self): """Attempt to transition to CANCELLED status.""" - return self.handle_transition(self.status, SalesOrderStatus.CANCELLED.value, self, self._action_cancel) + return self.handle_transition( + self.status, SalesOrderStatus.CANCELLED.value, self, self._action_cancel + ) + # endregion @property @@ -1042,7 +1147,10 @@ def after_save_sales_order(sender, instance: SalesOrder, created: bool, **kwargs - Ignore if the database is not ready for access - Ignore if data import is active """ - if not InvenTree.ready.canAppAccessDatabase(allow_test=True) or InvenTree.ready.isImportingData(): + if ( + not InvenTree.ready.canAppAccessDatabase(allow_test=True) + or InvenTree.ready.isImportingData() + ): return if created: @@ -1050,10 +1158,7 @@ def after_save_sales_order(sender, instance: SalesOrder, created: bool, **kwargs if getSetting('SALESORDER_DEFAULT_SHIPMENT'): # Create default shipment - SalesOrderShipment.objects.create( - order=instance, - reference='1', - ) + SalesOrderShipment.objects.create(order=instance, reference='1') # Notify the responsible users that the sales order has been created notify_responsible(instance, sender, exclude=instance.created_by) @@ -1071,7 +1176,9 @@ class PurchaseOrderAttachment(InvenTreeAttachment): """Return the directory path where PurchaseOrderAttachment files are located""" return os.path.join("po_files", str(self.order.id)) - order = models.ForeignKey(PurchaseOrder, on_delete=models.CASCADE, related_name="attachments") + order = models.ForeignKey( + PurchaseOrder, on_delete=models.CASCADE, related_name="attachments" + ) class SalesOrderAttachment(InvenTreeAttachment): @@ -1086,7 +1193,9 @@ class SalesOrderAttachment(InvenTreeAttachment): """Return the directory path where SalesOrderAttachment files are located""" return os.path.join("so_files", str(self.order.id)) - order = models.ForeignKey(SalesOrder, on_delete=models.CASCADE, related_name='attachments') + order = models.ForeignKey( + SalesOrder, on_delete=models.CASCADE, related_name='attachments' + ) class OrderLineItem(MetadataMixin, models.Model): @@ -1101,6 +1210,7 @@ class OrderLineItem(MetadataMixin, models.Model): class Meta: """Metaclass options. Abstract ensures no database table is created.""" + abstract = True def save(self, *args, **kwargs): @@ -1123,7 +1233,8 @@ class OrderLineItem(MetadataMixin, models.Model): verbose_name=_('Quantity'), help_text=_('Item quantity'), default=1, - max_digits=15, decimal_places=5, + max_digits=15, + decimal_places=5, validators=[MinValueValidator(0)], ) @@ -1133,20 +1244,31 @@ class OrderLineItem(MetadataMixin, models.Model): if self.price: return self.quantity * self.price - reference = models.CharField(max_length=100, blank=True, verbose_name=_('Reference'), help_text=_('Line item reference')) + reference = models.CharField( + max_length=100, + blank=True, + verbose_name=_('Reference'), + help_text=_('Line item reference'), + ) - notes = models.CharField(max_length=500, blank=True, verbose_name=_('Notes'), help_text=_('Line item notes')) + notes = models.CharField( + max_length=500, + blank=True, + verbose_name=_('Notes'), + help_text=_('Line item notes'), + ) link = InvenTreeURLField( - blank=True, - verbose_name=_('Link'), - help_text=_('Link to external page') + blank=True, verbose_name=_('Link'), help_text=_('Link to external page') ) target_date = models.DateField( - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Target Date'), - help_text=_('Target date for this line item (leave blank to use the target date from the order)'), + help_text=_( + 'Target date for this line item (leave blank to use the target date from the order)' + ), ) @@ -1159,16 +1281,19 @@ class OrderExtraLine(OrderLineItem): class Meta: """Metaclass options. Abstract ensures no database table is created.""" + abstract = True description = models.CharField( - max_length=250, blank=True, + max_length=250, + blank=True, verbose_name=_('Description'), - help_text=_('Line item description (optional)') + help_text=_('Line item description (optional)'), ) context = models.JSONField( - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Context'), help_text=_('Additional context for this line'), ) @@ -1176,7 +1301,8 @@ class OrderExtraLine(OrderLineItem): price = InvenTreeModelMoneyField( max_digits=19, decimal_places=6, - null=True, blank=True, + null=True, + blank=True, allow_negative=True, verbose_name=_('Price'), help_text=_('Unit price'), @@ -1191,7 +1317,11 @@ class PurchaseOrderLineItem(OrderLineItem): """ # Filter for determining if a particular PurchaseOrderLineItem is overdue - OVERDUE_FILTER = Q(received__lt=F('quantity')) & ~Q(target_date=None) & Q(target_date__lt=datetime.now().date()) + OVERDUE_FILTER = ( + Q(received__lt=F('quantity')) + & ~Q(target_date=None) + & Q(target_date__lt=datetime.now().date()) + ) @staticmethod def get_api_url(): @@ -1208,9 +1338,7 @@ class PurchaseOrderLineItem(OrderLineItem): if self.order.supplier and self.part: # Supplier part *must* point to the same supplier! if self.part.supplier != self.order.supplier: - raise ValidationError({ - 'part': _('Supplier part must match supplier') - }) + raise ValidationError({'part': _('Supplier part must match supplier')}) def __str__(self): """Render a string representation of a PurchaseOrderLineItem instance""" @@ -1218,13 +1346,15 @@ class PurchaseOrderLineItem(OrderLineItem): n=decimal2string(self.quantity), part=self.part.SKU if self.part else 'unknown part', supplier=self.order.supplier.name if self.order.supplier else _('deleted'), - po=self.order) + po=self.order, + ) order = models.ForeignKey( - PurchaseOrder, on_delete=models.CASCADE, + PurchaseOrder, + on_delete=models.CASCADE, related_name='lines', verbose_name=_('Order'), - help_text=_('Purchase Order') + help_text=_('Purchase Order'), ) def get_base_part(self): @@ -1237,8 +1367,10 @@ class PurchaseOrderLineItem(OrderLineItem): return self.part.part part = models.ForeignKey( - SupplierPart, on_delete=models.SET_NULL, - blank=False, null=True, + SupplierPart, + on_delete=models.SET_NULL, + blank=False, + null=True, related_name='purchase_order_line_items', verbose_name=_('Part'), help_text=_("Supplier part"), @@ -1249,13 +1381,14 @@ class PurchaseOrderLineItem(OrderLineItem): max_digits=15, default=0, verbose_name=_('Received'), - help_text=_('Number of items received') + help_text=_('Number of items received'), ) purchase_price = InvenTreeModelMoneyField( max_digits=19, decimal_places=6, - null=True, blank=True, + null=True, + blank=True, verbose_name=_('Purchase Price'), help_text=_('Unit purchase price'), ) @@ -1266,11 +1399,13 @@ class PurchaseOrderLineItem(OrderLineItem): return self.purchase_price destination = TreeForeignKey( - 'stock.StockLocation', on_delete=models.SET_NULL, + 'stock.StockLocation', + on_delete=models.SET_NULL, verbose_name=_('Destination'), related_name='po_lines', - blank=True, null=True, - help_text=_('Where does the Purchaser want this item to be stored?') + blank=True, + null=True, + help_text=_('Where does the Purchaser want this item to be stored?'), ) def get_destination(self): @@ -1280,7 +1415,9 @@ class PurchaseOrderLineItem(OrderLineItem): stock items location will be reported as the location for the entire line. """ - for item in stock.models.StockItem.objects.filter(supplier_part=self.part, purchase_order=self.order): + for item in stock.models.StockItem.objects.filter( + supplier_part=self.part, purchase_order=self.order + ): if item.location: return item.location if self.destination: @@ -1302,12 +1439,19 @@ class PurchaseOrderExtraLine(OrderExtraLine): title: title of line price: The unit price for this OrderLine """ + @staticmethod def get_api_url(): """Return the API URL associated with the PurchaseOrderExtraLine model""" return reverse('api-po-extra-line-list') - order = models.ForeignKey(PurchaseOrder, on_delete=models.CASCADE, related_name='extra_lines', verbose_name=_('Order'), help_text=_('Purchase Order')) + order = models.ForeignKey( + PurchaseOrder, + on_delete=models.CASCADE, + related_name='extra_lines', + verbose_name=_('Order'), + help_text=_('Purchase Order'), + ) class SalesOrderLineItem(OrderLineItem): @@ -1321,7 +1465,11 @@ class SalesOrderLineItem(OrderLineItem): """ # Filter for determining if a particular SalesOrderLineItem is overdue - OVERDUE_FILTER = Q(shipped__lt=F('quantity')) & ~Q(target_date=None) & Q(target_date__lt=datetime.now().date()) + OVERDUE_FILTER = ( + Q(shipped__lt=F('quantity')) + & ~Q(target_date=None) + & Q(target_date__lt=datetime.now().date()) + ) @staticmethod def get_api_url(): @@ -1348,24 +1496,24 @@ class SalesOrderLineItem(OrderLineItem): on_delete=models.CASCADE, related_name='lines', verbose_name=_('Order'), - help_text=_('Sales Order') + help_text=_('Sales Order'), ) part = models.ForeignKey( - 'part.Part', on_delete=models.SET_NULL, + 'part.Part', + on_delete=models.SET_NULL, related_name='sales_order_line_items', null=True, verbose_name=_('Part'), help_text=_('Part'), - limit_choices_to={ - 'salable': True, - 'virtual': False, - }) + limit_choices_to={'salable': True, 'virtual': False}, + ) sale_price = InvenTreeModelMoneyField( max_digits=19, decimal_places=6, - null=True, blank=True, + null=True, + blank=True, verbose_name=_('Sale Price'), help_text=_('Unit sale price'), ) @@ -1379,13 +1527,16 @@ class SalesOrderLineItem(OrderLineItem): verbose_name=_('Shipped'), help_text=_('Shipped quantity'), default=0, - max_digits=15, decimal_places=5, - validators=[MinValueValidator(0)] + max_digits=15, + decimal_places=5, + validators=[MinValueValidator(0)], ) def fulfilled_quantity(self): """Return the total stock quantity fulfilled against this line item.""" - query = self.order.stock_items.filter(part=self.part).aggregate(fulfilled=Coalesce(Sum('quantity'), Decimal(0))) + query = self.order.stock_items.filter(part=self.part).aggregate( + fulfilled=Coalesce(Sum('quantity'), Decimal(0)) + ) return query['fulfilled'] @@ -1394,7 +1545,9 @@ class SalesOrderLineItem(OrderLineItem): This is a summation of the quantity of each attached StockItem """ - query = self.allocations.aggregate(allocated=Coalesce(Sum('quantity'), Decimal(0))) + query = self.allocations.aggregate( + allocated=Coalesce(Sum('quantity'), Decimal(0)) + ) return query['allocated'] @@ -1431,10 +1584,9 @@ class SalesOrderShipment(InvenTreeNotesMixin, MetadataMixin, models.Model): class Meta: """Metaclass defines extra model options""" + # Shipment reference must be unique for a given sales order - unique_together = [ - 'order', 'reference', - ] + unique_together = ['order', 'reference'] @staticmethod def get_api_url(): @@ -1444,20 +1596,23 @@ class SalesOrderShipment(InvenTreeNotesMixin, MetadataMixin, models.Model): order = models.ForeignKey( SalesOrder, on_delete=models.CASCADE, - blank=False, null=False, + blank=False, + null=False, related_name='shipments', verbose_name=_('Order'), help_text=_('Sales Order'), ) shipment_date = models.DateField( - null=True, blank=True, + null=True, + blank=True, verbose_name=_('Shipment Date'), help_text=_('Date of shipment'), ) delivery_date = models.DateField( - null=True, blank=True, + null=True, + blank=True, verbose_name=_('Delivery Date'), help_text=_('Date of delivery of shipment'), ) @@ -1465,7 +1620,8 @@ class SalesOrderShipment(InvenTreeNotesMixin, MetadataMixin, models.Model): checked_by = models.ForeignKey( User, on_delete=models.SET_NULL, - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Checked By'), help_text=_('User who checked this shipment'), related_name='+', @@ -1496,9 +1652,7 @@ class SalesOrderShipment(InvenTreeNotesMixin, MetadataMixin, models.Model): ) link = InvenTreeURLField( - blank=True, - verbose_name=_('Link'), - help_text=_('Link to external page') + blank=True, verbose_name=_('Link'), help_text=_('Link to external page') ) def is_complete(self): @@ -1587,15 +1741,18 @@ class SalesOrderExtraLine(OrderExtraLine): title: title of line price: The unit price for this OrderLine """ + @staticmethod def get_api_url(): """Return the API URL associated with the SalesOrderExtraLine model""" return reverse('api-so-extra-line-list') order = models.ForeignKey( - SalesOrder, on_delete=models.CASCADE, + SalesOrder, + on_delete=models.CASCADE, related_name='extra_lines', - verbose_name=_('Order'), help_text=_('Sales Order') + verbose_name=_('Order'), + help_text=_('Sales Order'), ) @@ -1638,7 +1795,9 @@ class SalesOrderAllocation(models.Model): if self.line.part != self.item.part: variants = self.line.part.get_descendants(include_self=True) if self.line.part not in variants: - errors['item'] = _('Cannot allocate stock item to a line with a different part') + errors['item'] = _( + 'Cannot allocate stock item to a line with a different part' + ) except PartModels.Part.DoesNotExist: errors['line'] = _('Cannot allocate stock to a line without a part') @@ -1648,12 +1807,12 @@ class SalesOrderAllocation(models.Model): # Ensure that we do not 'over allocate' a stock item build_allocation_count = self.item.build_allocation_count() sales_allocation_count = self.item.sales_order_allocation_count( - exclude_allocations={ - "pk": self.pk, - } + exclude_allocations={"pk": self.pk} ) - total_allocation = build_allocation_count + sales_allocation_count + self.quantity + total_allocation = ( + build_allocation_count + sales_allocation_count + self.quantity + ) if total_allocation > self.item.quantity: errors['quantity'] = _('Stock item is over-allocated') @@ -1675,7 +1834,7 @@ class SalesOrderAllocation(models.Model): SalesOrderLineItem, on_delete=models.CASCADE, verbose_name=_('Line'), - related_name='allocations' + related_name='allocations', ) shipment = models.ForeignKey( @@ -1697,10 +1856,17 @@ class SalesOrderAllocation(models.Model): 'sales_order': None, }, verbose_name=_('Item'), - help_text=_('Select stock item to allocate') + help_text=_('Select stock item to allocate'), ) - quantity = RoundingDecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1, verbose_name=_('Quantity'), help_text=_('Enter stock allocation quantity')) + quantity = RoundingDecimalField( + max_digits=15, + decimal_places=5, + validators=[MinValueValidator(0)], + default=1, + verbose_name=_('Quantity'), + help_text=_('Enter stock allocation quantity'), + ) def get_location(self): """Return the value of the location associated with this allocation""" @@ -1720,10 +1886,7 @@ class SalesOrderAllocation(models.Model): order = self.line.order item = self.item.allocateToCustomer( - order.customer, - quantity=self.quantity, - order=order, - user=user + order.customer, quantity=self.quantity, order=order, user=user ) # Update the 'shipped' quantity @@ -1763,7 +1926,7 @@ class ReturnOrder(TotalPriceMixin, Order): def api_defaults(cls, request): """Return default values for this model when issuing an API OPTIONS request""" defaults = { - 'reference': order.validators.generate_next_return_order_reference(), + 'reference': order.validators.generate_next_return_order_reference() } return defaults @@ -1781,9 +1944,7 @@ class ReturnOrder(TotalPriceMixin, Order): verbose_name=_('Reference'), help_text=_('Return Order reference'), default=order.validators.generate_next_return_order_reference, - validators=[ - order.validators.validate_return_order_reference, - ] + validators=[order.validators.validate_return_order_reference], ) customer = models.ForeignKey( @@ -1804,25 +1965,29 @@ class ReturnOrder(TotalPriceMixin, Order): status = models.PositiveIntegerField( default=ReturnOrderStatus.PENDING.value, choices=ReturnOrderStatus.items(), - verbose_name=_('Status'), help_text=_('Return order status') + verbose_name=_('Status'), + help_text=_('Return order status'), ) customer_reference = models.CharField( - max_length=64, blank=True, + max_length=64, + blank=True, verbose_name=_('Customer Reference '), - help_text=_("Customer order reference code") + help_text=_("Customer order reference code"), ) issue_date = models.DateField( - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Issue Date'), - help_text=_('Date order was issued') + help_text=_('Date order was issued'), ) complete_date = models.DateField( - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Completion Date'), - help_text=_('Date order was completed') + help_text=_('Date order was completed'), ) # region state changes @@ -1854,7 +2019,7 @@ class ReturnOrder(TotalPriceMixin, Order): self, ReturnOrder, exclude=self.created_by, - content=InvenTreeNotificationBodies.OrderCanceled + content=InvenTreeNotificationBodies.OrderCanceled, ) def _action_complete(self, *args, **kwargs): @@ -1882,17 +2047,24 @@ class ReturnOrder(TotalPriceMixin, Order): @transaction.atomic def issue_order(self): """Attempt to transition to IN_PROGRESS status.""" - return self.handle_transition(self.status, ReturnOrderStatus.IN_PROGRESS.value, self, self._action_place) + return self.handle_transition( + self.status, ReturnOrderStatus.IN_PROGRESS.value, self, self._action_place + ) @transaction.atomic def complete_order(self): """Attempt to transition to COMPLETE status.""" - return self.handle_transition(self.status, ReturnOrderStatus.COMPLETE.value, self, self._action_complete) + return self.handle_transition( + self.status, ReturnOrderStatus.COMPLETE.value, self, self._action_complete + ) @transaction.atomic def cancel_order(self): """Attempt to transition to CANCELLED status.""" - return self.handle_transition(self.status, ReturnOrderStatus.CANCELLED.value, self, self._action_cancel) + return self.handle_transition( + self.status, ReturnOrderStatus.CANCELLED.value, self, self._action_cancel + ) + # endregion @transaction.atomic @@ -1959,9 +2131,7 @@ class ReturnOrderLineItem(OrderLineItem): class Meta: """Metaclass options for this model""" - unique_together = [ - ('order', 'item'), - ] + unique_together = [('order', 'item')] @staticmethod def get_api_url(): @@ -1974,7 +2144,7 @@ class ReturnOrderLineItem(OrderLineItem): if self.item and not self.item.serialized: raise ValidationError({ - 'item': _("Only serialized items can be assigned to a Return Order"), + 'item': _("Only serialized items can be assigned to a Return Order") }) order = models.ForeignKey( @@ -1990,11 +2160,12 @@ class ReturnOrderLineItem(OrderLineItem): on_delete=models.CASCADE, related_name='return_order_lines', verbose_name=_('Item'), - help_text=_('Select item to return from customer') + help_text=_('Select item to return from customer'), ) received_date = models.DateField( - null=True, blank=True, + null=True, + blank=True, verbose_name=_('Received Date'), help_text=_('The date this this return item was received'), ) @@ -2007,11 +2178,13 @@ class ReturnOrderLineItem(OrderLineItem): outcome = models.PositiveIntegerField( default=ReturnOrderLineStatus.PENDING.value, choices=ReturnOrderLineStatus.items(), - verbose_name=_('Outcome'), help_text=_('Outcome for this line item') + verbose_name=_('Outcome'), + help_text=_('Outcome for this line item'), ) price = InvenTreeModelMoneyField( - null=True, blank=True, + null=True, + blank=True, verbose_name=_('Price'), help_text=_('Cost associated with return or repair for this line item'), ) @@ -2026,9 +2199,11 @@ class ReturnOrderExtraLine(OrderExtraLine): return reverse('api-return-order-extra-line-list') order = models.ForeignKey( - ReturnOrder, on_delete=models.CASCADE, + ReturnOrder, + on_delete=models.CASCADE, related_name='extra_lines', - verbose_name=_('Order'), help_text=_('Return Order') + verbose_name=_('Order'), + help_text=_('Return Order'), ) @@ -2045,7 +2220,5 @@ class ReturnOrderAttachment(InvenTreeAttachment): return os.path.join('return_files', str(self.order.id)) order = models.ForeignKey( - ReturnOrder, - on_delete=models.CASCADE, - related_name='attachments', + ReturnOrder, on_delete=models.CASCADE, related_name='attachments' ) diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 2d2b3a53d0..4620d31421 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -5,8 +5,7 @@ from decimal import Decimal from django.core.exceptions import ValidationError as DjangoValidationError from django.db import models, transaction -from django.db.models import (BooleanField, Case, ExpressionWrapper, F, Q, - Value, When) +from django.db.models import BooleanField, Case, ExpressionWrapper, F, Q, Value, When from django.utils.translation import gettext_lazy as _ from rest_framework import serializers @@ -18,19 +17,27 @@ import part.filters import stock.models import stock.serializers from common.serializers import ProjectCodeSerializer -from company.serializers import (AddressBriefSerializer, - CompanyBriefSerializer, ContactSerializer, - SupplierPartSerializer) -from InvenTree.helpers import (extract_serial_numbers, hash_barcode, normalize, - str2bool) -from InvenTree.serializers import (InvenTreeAttachmentSerializer, - InvenTreeCurrencySerializer, - InvenTreeDecimalField, - InvenTreeModelSerializer, - InvenTreeMoneySerializer) -from InvenTree.status_codes import (PurchaseOrderStatusGroups, - ReturnOrderLineStatus, ReturnOrderStatus, - SalesOrderStatusGroups, StockStatus) +from company.serializers import ( + AddressBriefSerializer, + CompanyBriefSerializer, + ContactSerializer, + SupplierPartSerializer, +) +from InvenTree.helpers import extract_serial_numbers, hash_barcode, normalize, str2bool +from InvenTree.serializers import ( + InvenTreeAttachmentSerializer, + InvenTreeCurrencySerializer, + InvenTreeDecimalField, + InvenTreeModelSerializer, + InvenTreeMoneySerializer, +) +from InvenTree.status_codes import ( + PurchaseOrderStatusGroups, + ReturnOrderLineStatus, + ReturnOrderStatus, + SalesOrderStatusGroups, + StockStatus, +) from part.serializers import PartBriefSerializer from users.serializers import OwnerSerializer @@ -38,10 +45,7 @@ from users.serializers import OwnerSerializer class TotalPriceMixin(serializers.Serializer): """Serializer mixin which provides total price fields""" - total_price = InvenTreeMoneySerializer( - allow_null=True, - read_only=True, - ) + total_price = InvenTreeMoneySerializer(allow_null=True, read_only=True) order_currency = InvenTreeCurrencySerializer( allow_blank=True, @@ -74,13 +78,19 @@ class AbstractOrderSerializer(serializers.Serializer): contact_detail = ContactSerializer(source='contact', many=False, read_only=True) # Detail for responsible field - responsible_detail = OwnerSerializer(source='responsible', read_only=True, many=False) + responsible_detail = OwnerSerializer( + source='responsible', read_only=True, many=False + ) # Detail for project code field - project_code_detail = ProjectCodeSerializer(source='project_code', read_only=True, many=False) + project_code_detail = ProjectCodeSerializer( + source='project_code', read_only=True, many=False + ) # Detail for address field - address_detail = AddressBriefSerializer(source='address', many=False, read_only=True) + address_detail = AddressBriefSerializer( + source='address', many=False, read_only=True + ) # Boolean field indicating if this order is overdue (Note: must be annotated) overdue = serializers.BooleanField(required=False, read_only=True) @@ -95,9 +105,7 @@ class AbstractOrderSerializer(serializers.Serializer): @staticmethod def annotate_queryset(queryset): """Add extra information to the queryset""" - queryset = queryset.annotate( - line_items=SubqueryCount('lines') - ) + queryset = queryset.annotate(line_items=SubqueryCount('lines')) return queryset @@ -143,9 +151,7 @@ class AbstractExtraLineSerializer(serializers.Serializer): quantity = serializers.FloatField() - price = InvenTreeMoneySerializer( - allow_null=True - ) + price = InvenTreeMoneySerializer(allow_null=True) price_currency = InvenTreeCurrencySerializer() @@ -168,7 +174,9 @@ class AbstractExtraLineMeta: ] -class PurchaseOrderSerializer(TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer): +class PurchaseOrderSerializer( + TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer +): """Serializer for a PurchaseOrder object.""" class Meta: @@ -186,11 +194,7 @@ class PurchaseOrderSerializer(TotalPriceMixin, AbstractOrderSerializer, InvenTre 'order_currency', ]) - read_only_fields = [ - 'issue_date', - 'complete_date', - 'creation_date', - ] + read_only_fields = ['issue_date', 'complete_date', 'creation_date'] extra_kwargs = { 'supplier': {'required': True}, @@ -216,7 +220,9 @@ class PurchaseOrderSerializer(TotalPriceMixin, AbstractOrderSerializer, InvenTre queryset = AbstractOrderSerializer.annotate_queryset(queryset) queryset = queryset.annotate( - completed_lines=SubqueryCount('lines', filter=Q(quantity__lte=F('received'))) + completed_lines=SubqueryCount( + 'lines', filter=Q(quantity__lte=F('received')) + ) ) queryset = queryset.annotate( @@ -225,13 +231,15 @@ class PurchaseOrderSerializer(TotalPriceMixin, AbstractOrderSerializer, InvenTre order.models.PurchaseOrder.overdue_filter(), then=Value(True, output_field=BooleanField()), ), - default=Value(False, output_field=BooleanField()) + default=Value(False, output_field=BooleanField()), ) ) return queryset - supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True) + supplier_detail = CompanyBriefSerializer( + source='supplier', many=False, read_only=True + ) class PurchaseOrderCancelSerializer(serializers.Serializer): @@ -240,15 +248,13 @@ class PurchaseOrderCancelSerializer(serializers.Serializer): class Meta: """Metaclass options.""" - fields = [], + fields = ([],) def get_context_data(self): """Return custom context information about the order.""" self.order = self.context['order'] - return { - 'can_cancel': self.order.can_cancel, - } + return {'can_cancel': self.order.can_cancel} def save(self): """Save the serializer to 'cancel' the order""" @@ -288,9 +294,7 @@ class PurchaseOrderCompleteSerializer(serializers.Serializer): """Custom context information for this serializer.""" order = self.context['order'] - return { - 'is_complete': order.is_complete, - } + return {'is_complete': order.is_complete} def save(self): """Save the serializer to 'complete' the order""" @@ -365,15 +369,15 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer): """ queryset = queryset.annotate( total_price=ExpressionWrapper( - F('purchase_price') * F('quantity'), - output_field=models.DecimalField() + F('purchase_price') * F('quantity'), output_field=models.DecimalField() ) ) queryset = queryset.annotate( overdue=Case( When( - order.models.PurchaseOrderLineItem.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()) + order.models.PurchaseOrderLineItem.OVERDUE_FILTER, + then=Value(True, output_field=BooleanField()), ), default=Value(False, output_field=BooleanField()), ) @@ -403,15 +407,23 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer): total_price = serializers.FloatField(read_only=True) - part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True) + part_detail = PartBriefSerializer( + source='get_base_part', many=False, read_only=True + ) - supplier_part_detail = SupplierPartSerializer(source='part', many=False, read_only=True) + supplier_part_detail = SupplierPartSerializer( + source='part', many=False, read_only=True + ) purchase_price = InvenTreeMoneySerializer(allow_null=True) - destination_detail = stock.serializers.LocationBriefSerializer(source='get_destination', read_only=True) + destination_detail = stock.serializers.LocationBriefSerializer( + source='get_destination', read_only=True + ) - purchase_price_currency = InvenTreeCurrencySerializer(help_text=_('Purchase price currency')) + purchase_price_currency = InvenTreeCurrencySerializer( + help_text=_('Purchase price currency') + ) order_detail = PurchaseOrderSerializer(source='order', read_only=True, many=False) @@ -428,17 +440,16 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer): purchase_order = data.get('order', None) if not supplier_part: - raise ValidationError({ - 'part': _('Supplier part must be specified'), - }) + raise ValidationError({'part': _('Supplier part must be specified')}) if not purchase_order: - raise ValidationError({ - 'order': _('Purchase order must be specified'), - }) + raise ValidationError({'order': _('Purchase order must be specified')}) # Check that the supplier part and purchase order match - if supplier_part is not None and supplier_part.supplier != purchase_order.supplier: + if ( + supplier_part is not None + and supplier_part.supplier != purchase_order.supplier + ): raise ValidationError({ 'part': _('Supplier must match purchase order'), 'order': _('Purchase order must match supplier'), @@ -447,7 +458,9 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer): return data -class PurchaseOrderExtraLineSerializer(AbstractExtraLineSerializer, InvenTreeModelSerializer): +class PurchaseOrderExtraLineSerializer( + AbstractExtraLineSerializer, InvenTreeModelSerializer +): """Serializer for a PurchaseOrderExtraLine object.""" order_detail = PurchaseOrderSerializer(source='order', many=False, read_only=True) @@ -470,8 +483,7 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer): 'location', 'quantity', 'status', - 'batch_code' - 'serial_numbers', + 'batch_code' 'serial_numbers', ] line_item = serializers.PrimaryKeyRelatedField( @@ -499,10 +511,7 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer): ) quantity = serializers.DecimalField( - max_digits=15, - decimal_places=5, - min_value=0, - required=True, + max_digits=15, decimal_places=5, min_value=0, required=True ) def validate_quantity(self, quantity): @@ -529,9 +538,7 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer): ) status = serializers.ChoiceField( - choices=StockStatus.items(), - default=StockStatus.OK.value, - label=_('Status'), + choices=StockStatus.items(), default=StockStatus.OK.value, label=_('Status') ) barcode = serializers.CharField( @@ -573,10 +580,11 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer): # Does the quantity need to be "integer" (for trackable parts?) if base_part.trackable: - if Decimal(base_quantity) != int(base_quantity): raise ValidationError({ - 'quantity': _('An integer quantity must be provided for trackable parts'), + 'quantity': _( + 'An integer quantity must be provided for trackable parts' + ) }) # If serial numbers are provided @@ -584,14 +592,10 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer): try: # Pass the serial numbers through to the parent serializer once validated data['serials'] = extract_serial_numbers( - serial_numbers, - base_quantity, - base_part.get_latest_serial_number() + serial_numbers, base_quantity, base_part.get_latest_serial_number() ) except DjangoValidationError as e: - raise ValidationError({ - 'serial_numbers': e.messages, - }) + raise ValidationError({'serial_numbers': e.messages}) return data @@ -602,10 +606,7 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer): class Meta: """Metaclass options.""" - fields = [ - 'items', - 'location', - ] + fields = ['items', 'location'] items = PurchaseOrderLineItemReceiveSerializer(many=True) @@ -634,7 +635,6 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer): # Check if the location is not specified for any particular item for item in items: - line = item['line_item'] if not item.get('location', None): @@ -647,7 +647,7 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer): if not item['location']: raise ValidationError({ - 'location': _("Destination location must be specified"), + 'location': _("Destination location must be specified") }) # Ensure barcodes are unique @@ -677,9 +677,12 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer): # Now we can actually receive the items into stock with transaction.atomic(): for item in items: - # Select location (in descending order of priority) - loc = location or item.get('location', None) or item['line_item'].get_destination() + loc = ( + location + or item.get('location', None) + or item['line_item'].get_destination() + ) try: order.receive_line_item( @@ -705,12 +708,12 @@ class PurchaseOrderAttachmentSerializer(InvenTreeAttachmentSerializer): model = order.models.PurchaseOrderAttachment - fields = InvenTreeAttachmentSerializer.attachment_fields([ - 'order', - ]) + fields = InvenTreeAttachmentSerializer.attachment_fields(['order']) -class SalesOrderSerializer(TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer): +class SalesOrderSerializer( + TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer +): """Serializer for the SalesOrder model class""" class Meta: @@ -727,15 +730,9 @@ class SalesOrderSerializer(TotalPriceMixin, AbstractOrderSerializer, InvenTreeMo 'order_currency', ]) - read_only_fields = [ - 'status', - 'creation_date', - 'shipment_date', - ] + read_only_fields = ['status', 'creation_date', 'shipment_date'] - extra_kwargs = { - 'order_currency': {'required': False}, - } + extra_kwargs = {'order_currency': {'required': False}} def __init__(self, *args, **kwargs): """Initialization routine for the serializer""" @@ -766,13 +763,15 @@ class SalesOrderSerializer(TotalPriceMixin, AbstractOrderSerializer, InvenTreeMo order.models.SalesOrder.overdue_filter(), then=Value(True, output_field=BooleanField()), ), - default=Value(False, output_field=BooleanField()) + default=Value(False, output_field=BooleanField()), ) ) return queryset - customer_detail = CompanyBriefSerializer(source='customer', many=False, read_only=True) + customer_detail = CompanyBriefSerializer( + source='customer', many=False, read_only=True + ) class SalesOrderIssueSerializer(serializers.Serializer): @@ -780,6 +779,7 @@ class SalesOrderIssueSerializer(serializers.Serializer): class Meta: """Metaclass options""" + fields = [] def save(self): @@ -843,19 +843,31 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer): self.fields.pop('customer_detail') part = serializers.PrimaryKeyRelatedField(source='item.part', read_only=True) - order = serializers.PrimaryKeyRelatedField(source='line.order', many=False, read_only=True) + order = serializers.PrimaryKeyRelatedField( + source='line.order', many=False, read_only=True + ) serial = serializers.CharField(source='get_serial', read_only=True) quantity = serializers.FloatField(read_only=False) - location = serializers.PrimaryKeyRelatedField(source='item.location', many=False, read_only=True) + location = serializers.PrimaryKeyRelatedField( + source='item.location', many=False, read_only=True + ) # Extra detail fields order_detail = SalesOrderSerializer(source='line.order', many=False, read_only=True) part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True) - item_detail = stock.serializers.StockItemSerializer(source='item', many=False, read_only=True) - location_detail = stock.serializers.LocationSerializer(source='item.location', many=False, read_only=True) - customer_detail = CompanyBriefSerializer(source='line.order.customer', many=False, read_only=True) + item_detail = stock.serializers.StockItemSerializer( + source='item', many=False, read_only=True + ) + location_detail = stock.serializers.LocationSerializer( + source='item.location', many=False, read_only=True + ) + customer_detail = CompanyBriefSerializer( + source='line.order.customer', many=False, read_only=True + ) - shipment_date = serializers.DateField(source='shipment.shipment_date', read_only=True) + shipment_date = serializers.DateField( + source='shipment.shipment_date', read_only=True + ) class SalesOrderLineItemSerializer(InvenTreeModelSerializer): @@ -922,7 +934,9 @@ class SalesOrderLineItemSerializer(InvenTreeModelSerializer): queryset = queryset.annotate( overdue=Case( When( - Q(order__status__in=SalesOrderStatusGroups.OPEN) & order.models.SalesOrderLineItem.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()), + Q(order__status__in=SalesOrderStatusGroups.OPEN) + & order.models.SalesOrderLineItem.OVERDUE_FILTER, + then=Value(True, output_field=BooleanField()), ), default=Value(False, output_field=BooleanField()), ) @@ -932,43 +946,60 @@ class SalesOrderLineItemSerializer(InvenTreeModelSerializer): # To do this, we need to look at the total stock and any allocations queryset = queryset.alias( total_stock=part.filters.annotate_total_stock(reference='part__'), - allocated_to_sales_orders=part.filters.annotate_sales_order_allocations(reference='part__'), - allocated_to_build_orders=part.filters.annotate_build_order_allocations(reference='part__'), + allocated_to_sales_orders=part.filters.annotate_sales_order_allocations( + reference='part__' + ), + allocated_to_build_orders=part.filters.annotate_build_order_allocations( + reference='part__' + ), ) queryset = queryset.annotate( available_stock=ExpressionWrapper( - F('total_stock') - F('allocated_to_sales_orders') - F('allocated_to_build_orders'), - output_field=models.DecimalField() + F('total_stock') + - F('allocated_to_sales_orders') + - F('allocated_to_build_orders'), + output_field=models.DecimalField(), ) ) # Filter for "variant" stock: Variant stock items must be salable and active - variant_stock_query = part.filters.variant_stock_query(reference='part__').filter( - part__salable=True, - part__active=True - ) + variant_stock_query = part.filters.variant_stock_query( + reference='part__' + ).filter(part__salable=True, part__active=True) # Also add in available "variant" stock queryset = queryset.alias( - variant_stock_total=part.filters.annotate_variant_quantity(variant_stock_query, reference='quantity'), - variant_bo_allocations=part.filters.annotate_variant_quantity(variant_stock_query, reference='sales_order_allocations__quantity'), - variant_so_allocations=part.filters.annotate_variant_quantity(variant_stock_query, reference='allocations__quantity'), + variant_stock_total=part.filters.annotate_variant_quantity( + variant_stock_query, reference='quantity' + ), + variant_bo_allocations=part.filters.annotate_variant_quantity( + variant_stock_query, reference='sales_order_allocations__quantity' + ), + variant_so_allocations=part.filters.annotate_variant_quantity( + variant_stock_query, reference='allocations__quantity' + ), ) queryset = queryset.annotate( available_variant_stock=ExpressionWrapper( - F('variant_stock_total') - F('variant_bo_allocations') - F('variant_so_allocations'), + F('variant_stock_total') + - F('variant_bo_allocations') + - F('variant_so_allocations'), output_field=models.DecimalField(), ) ) return queryset - customer_detail = CompanyBriefSerializer(source='order.customer', many=False, read_only=True) + customer_detail = CompanyBriefSerializer( + source='order.customer', many=False, read_only=True + ) order_detail = SalesOrderSerializer(source='order', many=False, read_only=True) part_detail = PartBriefSerializer(source='part', many=False, read_only=True) - allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True) + allocations = SalesOrderAllocationSerializer( + many=True, read_only=True, location_detail=True + ) # Annotated fields overdue = serializers.BooleanField(required=False, read_only=True) @@ -983,7 +1014,9 @@ class SalesOrderLineItemSerializer(InvenTreeModelSerializer): sale_price = InvenTreeMoneySerializer(allow_null=True) - sale_price_currency = InvenTreeCurrencySerializer(help_text=_('Sale price currency')) + sale_price_currency = InvenTreeCurrencySerializer( + help_text=_('Sale price currency') + ) class SalesOrderShipmentSerializer(InvenTreeModelSerializer): @@ -1009,7 +1042,9 @@ class SalesOrderShipmentSerializer(InvenTreeModelSerializer): 'notes', ] - allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True) + allocations = SalesOrderAllocationSerializer( + many=True, read_only=True, location_detail=True + ) order_detail = SalesOrderSerializer(source='order', read_only=True, many=False) @@ -1081,11 +1116,7 @@ class SalesOrderShipmentAllocationItemSerializer(serializers.Serializer): class Meta: """Metaclass options.""" - fields = [ - 'line_item', - 'stock_item', - 'quantity', - ] + fields = ['line_item', 'stock_item', 'quantity'] line_item = serializers.PrimaryKeyRelatedField( queryset=order.models.SalesOrderLineItem.objects.all(), @@ -1117,10 +1148,7 @@ class SalesOrderShipmentAllocationItemSerializer(serializers.Serializer): ) quantity = serializers.DecimalField( - max_digits=15, - decimal_places=5, - min_value=0, - required=True + max_digits=15, decimal_places=5, min_value=0, required=True ) def validate_quantity(self, quantity): @@ -1143,15 +1171,13 @@ class SalesOrderShipmentAllocationItemSerializer(serializers.Serializer): if stock_item.serialized and quantity != 1: raise ValidationError({ - 'quantity': _("Quantity must be 1 for serialized stock item"), + 'quantity': _("Quantity must be 1 for serialized stock item") }) q = normalize(stock_item.unallocated_quantity()) if quantity > q: - raise ValidationError({ - 'quantity': _(f"Available quantity ({q}) exceeded") - }) + raise ValidationError({'quantity': _(f"Available quantity ({q}) exceeded")}) return data @@ -1206,8 +1232,7 @@ class SalesOrderCompleteSerializer(serializers.Serializer): user = getattr(request, 'user', None) order.complete_order( - user, - allow_incomplete_lines=str2bool(data.get('accept_incomplete', False)), + user, allow_incomplete_lines=str2bool(data.get('accept_incomplete', False)) ) @@ -1218,9 +1243,7 @@ class SalesOrderCancelSerializer(serializers.Serializer): """Add extra context data to the serializer""" order = self.context['order'] - return { - 'can_cancel': order.can_cancel, - } + return {'can_cancel': order.can_cancel} def save(self): """Save the serializer to cancel the order""" @@ -1235,12 +1258,7 @@ class SalesOrderSerialAllocationSerializer(serializers.Serializer): class Meta: """Metaclass options.""" - fields = [ - 'line_item', - 'quantity', - 'serial_numbers', - 'shipment', - ] + fields = ['line_item', 'quantity', 'serial_numbers', 'shipment'] line_item = serializers.PrimaryKeyRelatedField( queryset=order.models.SalesOrderLineItem.objects.all(), @@ -1261,10 +1279,7 @@ class SalesOrderSerialAllocationSerializer(serializers.Serializer): return line_item quantity = serializers.IntegerField( - min_value=1, - required=True, - allow_null=False, - label=_('Quantity'), + min_value=1, required=True, allow_null=False, label=_('Quantity') ) serial_numbers = serializers.CharField( @@ -1315,14 +1330,10 @@ class SalesOrderSerialAllocationSerializer(serializers.Serializer): try: data['serials'] = extract_serial_numbers( - serial_numbers, - quantity, - part.get_latest_serial_number() + serial_numbers, quantity, part.get_latest_serial_number() ) except DjangoValidationError as e: - raise ValidationError({ - 'serial_numbers': e.messages, - }) + raise ValidationError({'serial_numbers': e.messages}) serials_not_exist = [] serials_allocated = [] @@ -1330,9 +1341,7 @@ class SalesOrderSerialAllocationSerializer(serializers.Serializer): for serial in data['serials']: items = stock.models.StockItem.objects.filter( - part=part, - serial=serial, - quantity=1, + part=part, serial=serial, quantity=1 ) if not items.exists(): @@ -1347,24 +1356,18 @@ class SalesOrderSerialAllocationSerializer(serializers.Serializer): serials_allocated.append(str(serial)) if len(serials_not_exist) > 0: - error_msg = _("No match found for the following serial numbers") error_msg += ": " error_msg += ",".join(serials_not_exist) - raise ValidationError({ - 'serial_numbers': error_msg - }) + raise ValidationError({'serial_numbers': error_msg}) if len(serials_allocated) > 0: - error_msg = _("The following serial numbers are already allocated") error_msg += ": " error_msg += ",".join(serials_allocated) - raise ValidationError({ - 'serial_numbers': error_msg, - }) + raise ValidationError({'serial_numbers': error_msg}) data['stock_items'] = stock_items_to_allocate @@ -1382,10 +1385,7 @@ class SalesOrderSerialAllocationSerializer(serializers.Serializer): for stock_item in stock_items: # Create a new SalesOrderAllocation order.models.SalesOrderAllocation.objects.create( - line=line_item, - item=stock_item, - quantity=1, - shipment=shipment + line=line_item, item=stock_item, quantity=1, shipment=shipment ) @@ -1395,10 +1395,7 @@ class SalesOrderShipmentAllocationSerializer(serializers.Serializer): class Meta: """Metaclass options.""" - fields = [ - 'items', - 'shipment', - ] + fields = ['items', 'shipment'] items = SalesOrderShipmentAllocationItemSerializer(many=True) @@ -1445,7 +1442,6 @@ class SalesOrderShipmentAllocationSerializer(serializers.Serializer): with transaction.atomic(): for entry in items: - # Create a new SalesOrderAllocation allocation = order.models.SalesOrderAllocation( line=entry.get('line_item'), @@ -1458,7 +1454,9 @@ class SalesOrderShipmentAllocationSerializer(serializers.Serializer): allocation.save() -class SalesOrderExtraLineSerializer(AbstractExtraLineSerializer, InvenTreeModelSerializer): +class SalesOrderExtraLineSerializer( + AbstractExtraLineSerializer, InvenTreeModelSerializer +): """Serializer for a SalesOrderExtraLine object.""" class Meta(AbstractExtraLineMeta): @@ -1477,12 +1475,12 @@ class SalesOrderAttachmentSerializer(InvenTreeAttachmentSerializer): model = order.models.SalesOrderAttachment - fields = InvenTreeAttachmentSerializer.attachment_fields([ - 'order', - ]) + fields = InvenTreeAttachmentSerializer.attachment_fields(['order']) -class ReturnOrderSerializer(AbstractOrderSerializer, TotalPriceMixin, InvenTreeModelSerializer): +class ReturnOrderSerializer( + AbstractOrderSerializer, TotalPriceMixin, InvenTreeModelSerializer +): """Serializer for the ReturnOrder model class""" class Meta: @@ -1498,9 +1496,7 @@ class ReturnOrderSerializer(AbstractOrderSerializer, TotalPriceMixin, InvenTreeM 'total_price', ]) - read_only_fields = [ - 'creation_date', - ] + read_only_fields = ['creation_date'] def __init__(self, *args, **kwargs): """Initialization routine for the serializer""" @@ -1517,7 +1513,9 @@ class ReturnOrderSerializer(AbstractOrderSerializer, TotalPriceMixin, InvenTreeM queryset = AbstractOrderSerializer.annotate_queryset(queryset) queryset = queryset.annotate( - completed_lines=SubqueryCount('lines', filter=~Q(outcome=ReturnOrderLineStatus.PENDING.value)) + completed_lines=SubqueryCount( + 'lines', filter=~Q(outcome=ReturnOrderLineStatus.PENDING.value) + ) ) queryset = queryset.annotate( @@ -1526,13 +1524,15 @@ class ReturnOrderSerializer(AbstractOrderSerializer, TotalPriceMixin, InvenTreeM order.models.ReturnOrder.overdue_filter(), then=Value(True, output_field=BooleanField()), ), - default=Value(False, output_field=BooleanField()) + default=Value(False, output_field=BooleanField()), ) ) return queryset - customer_detail = CompanyBriefSerializer(source='customer', many=False, read_only=True) + customer_detail = CompanyBriefSerializer( + source='customer', many=False, read_only=True + ) class ReturnOrderIssueSerializer(serializers.Serializer): @@ -1540,6 +1540,7 @@ class ReturnOrderIssueSerializer(serializers.Serializer): class Meta: """Metaclass options""" + fields = [] def save(self): @@ -1553,6 +1554,7 @@ class ReturnOrderCancelSerializer(serializers.Serializer): class Meta: """Metaclass options""" + fields = [] def save(self): @@ -1566,6 +1568,7 @@ class ReturnOrderCompleteSerializer(serializers.Serializer): class Meta: """Metaclass options""" + fields = [] def save(self): @@ -1579,9 +1582,8 @@ class ReturnOrderLineItemReceiveSerializer(serializers.Serializer): class Meta: """Metaclass options""" - fields = [ - 'item', - ] + + fields = ['item'] item = serializers.PrimaryKeyRelatedField( queryset=order.models.ReturnOrderLineItem.objects.all(), @@ -1608,10 +1610,7 @@ class ReturnOrderReceiveSerializer(serializers.Serializer): class Meta: """Metaclass options""" - fields = [ - 'items', - 'location', - ] + fields = ['items', 'location'] items = ReturnOrderLineItemReceiveSerializer(many=True) @@ -1628,7 +1627,9 @@ class ReturnOrderReceiveSerializer(serializers.Serializer): """Perform data validation for this serializer""" order = self.context['order'] if order.status != ReturnOrderStatus.IN_PROGRESS: - raise ValidationError(_("Items can only be received against orders which are in progress")) + raise ValidationError( + _("Items can only be received against orders which are in progress") + ) data = super().validate(data) @@ -1652,11 +1653,7 @@ class ReturnOrderReceiveSerializer(serializers.Serializer): with transaction.atomic(): for item in items: line_item = item['item'] - order.receive_line_item( - line_item, - location, - request.user - ) + order.receive_line_item(line_item, location, request.user) class ReturnOrderLineItemSerializer(InvenTreeModelSerializer): @@ -1703,18 +1700,23 @@ class ReturnOrderLineItemSerializer(InvenTreeModelSerializer): self.fields.pop('part_detail') order_detail = ReturnOrderSerializer(source='order', many=False, read_only=True) - item_detail = stock.serializers.StockItemSerializer(source='item', many=False, read_only=True) + item_detail = stock.serializers.StockItemSerializer( + source='item', many=False, read_only=True + ) part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True) price = InvenTreeMoneySerializer(allow_null=True) price_currency = InvenTreeCurrencySerializer(help_text=_('Line price currency')) -class ReturnOrderExtraLineSerializer(AbstractExtraLineSerializer, InvenTreeModelSerializer): +class ReturnOrderExtraLineSerializer( + AbstractExtraLineSerializer, InvenTreeModelSerializer +): """Serializer for a ReturnOrderExtraLine object""" class Meta(AbstractExtraLineMeta): """Metaclass options""" + model = order.models.ReturnOrderExtraLine order_detail = ReturnOrderSerializer(source='order', many=False, read_only=True) @@ -1728,6 +1730,4 @@ class ReturnOrderAttachmentSerializer(InvenTreeAttachmentSerializer): model = order.models.ReturnOrderAttachment - fields = InvenTreeAttachmentSerializer.attachment_fields([ - 'order', - ]) + fields = InvenTreeAttachmentSerializer.attachment_fields(['order']) diff --git a/InvenTree/order/tasks.py b/InvenTree/order/tasks.py index 6d6a2eb7d6..f97bcd2ac6 100644 --- a/InvenTree/order/tasks.py +++ b/InvenTree/order/tasks.py @@ -7,8 +7,7 @@ from django.utils.translation import gettext_lazy as _ import common.notifications import InvenTree.helpers_model import order.models -from InvenTree.status_codes import (PurchaseOrderStatusGroups, - SalesOrderStatusGroups) +from InvenTree.status_codes import PurchaseOrderStatusGroups, SalesOrderStatusGroups from InvenTree.tasks import ScheduledTask, scheduled_task from plugin.events import trigger_event @@ -29,30 +28,19 @@ def notify_overdue_purchase_order(po: order.models.PurchaseOrder): 'order': po, 'name': name, 'message': _(f'Purchase order {po} is now overdue'), - 'link': InvenTree.helpers_model.construct_absolute_url( - po.get_absolute_url(), - ), - 'template': { - 'html': 'email/overdue_purchase_order.html', - 'subject': name, - } + 'link': InvenTree.helpers_model.construct_absolute_url(po.get_absolute_url()), + 'template': {'html': 'email/overdue_purchase_order.html', 'subject': name}, } event_name = 'order.overdue_purchase_order' # Send a notification to the appropriate users common.notifications.trigger_notification( - po, - event_name, - targets=targets, - context=context, + po, event_name, targets=targets, context=context ) # Register a matching event to the plugin system - trigger_event( - event_name, - purchase_order=po.pk, - ) + trigger_event(event_name, purchase_order=po.pk) @scheduled_task(ScheduledTask.DAILY) @@ -66,8 +54,7 @@ def check_overdue_purchase_orders(): yesterday = datetime.now().date() - timedelta(days=1) overdue_orders = order.models.PurchaseOrder.objects.filter( - target_date=yesterday, - status__in=PurchaseOrderStatusGroups.OPEN, + target_date=yesterday, status__in=PurchaseOrderStatusGroups.OPEN ) for po in overdue_orders: @@ -90,30 +77,19 @@ def notify_overdue_sales_order(so: order.models.SalesOrder): 'order': so, 'name': name, 'message': _(f"Sales order {so} is now overdue"), - 'link': InvenTree.helpers_model.construct_absolute_url( - so.get_absolute_url(), - ), - 'template': { - 'html': 'email/overdue_sales_order.html', - 'subject': name, - } + 'link': InvenTree.helpers_model.construct_absolute_url(so.get_absolute_url()), + 'template': {'html': 'email/overdue_sales_order.html', 'subject': name}, } event_name = 'order.overdue_sales_order' # Send a notification to the appropriate users common.notifications.trigger_notification( - so, - event_name, - targets=targets, - context=context, + so, event_name, targets=targets, context=context ) # Register a matching event to the plugin system - trigger_event( - event_name, - sales_order=so.pk, - ) + trigger_event(event_name, sales_order=so.pk) @scheduled_task(ScheduledTask.DAILY) @@ -127,8 +103,7 @@ def check_overdue_sales_orders(): yesterday = datetime.now().date() - timedelta(days=1) overdue_orders = order.models.SalesOrder.objects.filter( - target_date=yesterday, - status__in=SalesOrderStatusGroups.OPEN, + target_date=yesterday, status__in=SalesOrderStatusGroups.OPEN ) for po in overdue_orders: diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 2b7aa76782..7540e437ff 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -15,9 +15,14 @@ from rest_framework import status from common.settings import currency_codes from company.models import Company -from InvenTree.status_codes import (PurchaseOrderStatus, ReturnOrderLineStatus, - ReturnOrderStatus, SalesOrderStatus, - SalesOrderStatusGroups, StockStatus) +from InvenTree.status_codes import ( + PurchaseOrderStatus, + ReturnOrderLineStatus, + ReturnOrderStatus, + SalesOrderStatus, + SalesOrderStatusGroups, + StockStatus, +) from InvenTree.unit_test import InvenTreeAPITestCase from order import models from part.models import Part @@ -26,6 +31,7 @@ from stock.models import StockItem class OrderTest(InvenTreeAPITestCase): """Base class for order API unit testing""" + fixtures = [ 'category', 'part', @@ -37,17 +43,11 @@ class OrderTest(InvenTreeAPITestCase): 'sales_order', ] - roles = [ - 'purchase_order.change', - 'sales_order.change', - ] + roles = ['purchase_order.change', 'sales_order.change'] def filter(self, filters, count): """Test API filters.""" - response = self.get( - self.LIST_URL, - filters - ) + response = self.get(self.LIST_URL, filters) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), count) @@ -80,28 +80,36 @@ class PurchaseOrderTest(OrderTest): self.assertEqual(field_data[k], v) # Checks for the 'order_currency' field - check_options(post, 'order_currency', { - 'type': 'choice', - 'required': False, - 'read_only': False, - 'label': 'Order Currency', - 'help_text': 'Currency for this order (leave blank to use company default)', - }) + check_options( + post, + 'order_currency', + { + 'type': 'choice', + 'required': False, + 'read_only': False, + 'label': 'Order Currency', + 'help_text': 'Currency for this order (leave blank to use company default)', + }, + ) # Checks for the 'reference' field - check_options(post, 'reference', { - 'type': 'string', - 'required': True, - 'read_only': False, - 'label': 'Reference', - }) + check_options( + post, + 'reference', + { + 'type': 'string', + 'required': True, + 'read_only': False, + 'label': 'Reference', + }, + ) # Checks for the 'supplier' field - check_options(post, 'supplier', { - 'type': 'related field', - 'required': True, - 'api_url': '/api/company/', - }) + check_options( + post, + 'supplier', + {'type': 'related field', 'required': True, 'api_url': '/api/company/'}, + ) def test_po_list(self): """Test the PurchaseOrder list API endpoint""" @@ -157,10 +165,7 @@ class PurchaseOrderTest(OrderTest): for supplier in Company.objects.filter(is_supplier=True): for _idx in range(10): new_orders.append( - models.PurchaseOrder( - supplier=supplier, - reference=f'PO-{idx + 100}' - ) + models.PurchaseOrder(supplier=supplier, reference=f'PO-{idx + 100}') ) idx += 1 @@ -190,7 +195,9 @@ class PurchaseOrderTest(OrderTest): # List all purchase orders for limit in [1, 5, 10, 100]: with CaptureQueriesContext(connection) as ctx: - response = self.get(self.LIST_URL, data={'limit': limit}, expected_code=200) + response = self.get( + self.LIST_URL, data={'limit': limit}, expected_code=200 + ) # Total database queries must be below 15, independent of the number of results self.assertLess(len(ctx), 15) @@ -245,7 +252,7 @@ class PurchaseOrderTest(OrderTest): order = models.PurchaseOrder.objects.get(pk=response.data['pk']) self.assertEqual(order.reference, 'PO-92233720368547758089999999999999999') - self.assertEqual(order.reference_int, 0x7fffffff) + self.assertEqual(order.reference_int, 0x7FFFFFFF) def test_po_attachments(self): """Test the list endpoint for the PurchaseOrderAttachment model""" @@ -270,7 +277,7 @@ class PurchaseOrderTest(OrderTest): 'reference': '123456789-xyz', 'description': 'PO created via the API', }, - expected_code=403 + expected_code=403, ) # And no new PurchaseOrder objects should have been created @@ -288,7 +295,7 @@ class PurchaseOrderTest(OrderTest): 'reference': 'PO-123456789', 'description': 'PO created via the API', }, - expected_code=201 + expected_code=201, ) self.assertEqual(models.PurchaseOrder.objects.count(), n + 1) @@ -303,7 +310,7 @@ class PurchaseOrderTest(OrderTest): 'reference': '123456789-xyz', 'description': 'A different description', }, - expected_code=400 + expected_code=400, ) self.assertEqual(models.PurchaseOrder.objects.count(), n + 1) @@ -316,13 +323,7 @@ class PurchaseOrderTest(OrderTest): self.assertEqual(response.data['reference'], 'PO-123456789') # Try to alter (edit) the PurchaseOrder - response = self.patch( - url, - { - 'reference': 'PO-12345', - }, - expected_code=200 - ) + response = self.patch(url, {'reference': 'PO-12345'}, expected_code=200) # Reference should have changed self.assertEqual(response.data['reference'], 'PO-12345') @@ -353,7 +354,7 @@ class PurchaseOrderTest(OrderTest): 'supplier': 1, 'description': 'A test purchase order', }, - expected_code=201 + expected_code=201, ) def test_po_duplicate(self): @@ -368,11 +369,11 @@ class PurchaseOrderTest(OrderTest): # Add some extra line items to this order for idx in range(5): - lines.append(models.PurchaseOrderExtraLine( - order=po, - quantity=idx + 10, - reference='some reference', - )) + lines.append( + models.PurchaseOrderExtraLine( + order=po, quantity=idx + 10, reference='some reference' + ) + ) # bulk create orders models.PurchaseOrderExtraLine.objects.bulk_create(lines) @@ -390,11 +391,7 @@ class PurchaseOrderTest(OrderTest): data['reference'] = 'PO-9999' # Duplicate via the API - response = self.post( - reverse('api-po-list'), - data, - expected_code=400 - ) + response = self.post(reverse('api-po-list'), data, expected_code=400) data['duplicate_order'] = 1 data['duplicate_line_items'] = True @@ -403,11 +400,7 @@ class PurchaseOrderTest(OrderTest): data['reference'] = 'PO-9999' # Duplicate via the API - response = self.post( - reverse('api-po-list'), - data, - expected_code=201 - ) + response = self.post(reverse('api-po-list'), data, expected_code=201) # Order is for the same supplier self.assertEqual(response.data['supplier'], po.supplier.pk) @@ -421,11 +414,7 @@ class PurchaseOrderTest(OrderTest): data['duplicate_line_items'] = False data['duplicate_extra_lines'] = True - response = self.post( - reverse('api-po-list'), - data, - expected_code=201, - ) + response = self.post(reverse('api-po-list'), data, expected_code=201) po_dup = models.PurchaseOrder.objects.get(pk=response.data['pk']) @@ -445,11 +434,7 @@ class PurchaseOrderTest(OrderTest): self.assignRole('purchase_order.add') - self.post( - url, - {}, - expected_code=201, - ) + self.post(url, {}, expected_code=201) po.refresh_from_db() @@ -474,16 +459,12 @@ class PurchaseOrderTest(OrderTest): # Should fail due to incomplete lines response = self.post(url, {}, expected_code=400) - self.assertIn('Order has incomplete line items', str(response.data['accept_incomplete'])) + self.assertIn( + 'Order has incomplete line items', str(response.data['accept_incomplete']) + ) # Post again, accepting incomplete line items - self.post( - url, - { - 'accept_incomplete': True, - }, - expected_code=201 - ) + self.post(url, {'accept_incomplete': True}, expected_code=201) po.refresh_from_db() @@ -520,37 +501,36 @@ class PurchaseOrderTest(OrderTest): 'description': f'Calendar PO {i}', 'target_date': f'2024-12-{i:02d}', }, - expected_code=201 + expected_code=201, ) # Get some of these orders with target date, complete or cancel them for po in models.PurchaseOrder.objects.filter(target_date__isnull=False): - if po.reference in ['PO-11000001', 'PO-11000002', 'PO-11000003', 'PO-11000004']: + if po.reference in [ + 'PO-11000001', + 'PO-11000002', + 'PO-11000003', + 'PO-11000004', + ]: # Set issued status for these POs self.post( - reverse('api-po-issue', kwargs={'pk': po.pk}), - {}, - expected_code=201 + reverse('api-po-issue', kwargs={'pk': po.pk}), {}, expected_code=201 ) if po.reference in ['PO-11000001', 'PO-11000002']: # Set complete status for these POs self.post( reverse('api-po-complete', kwargs={'pk': po.pk}), - { - 'accept_incomplete': True, - }, - expected_code=201 + {'accept_incomplete': True}, + expected_code=201, ) elif po.reference in ['PO-11000005', 'PO-11000006']: # Set cancel status for these POs self.post( reverse('api-po-cancel', kwargs={'pk': po.pk}), - { - 'accept_incomplete': True, - }, - expected_code=201 + {'accept_incomplete': True}, + expected_code=201, ) url = reverse('api-po-so-calendar', kwargs={'ordertype': 'purchase-order'}) @@ -558,7 +538,11 @@ class PurchaseOrderTest(OrderTest): # Test without completed orders response = self.get(url, expected_code=200, format=None) - number_orders = len(models.PurchaseOrder.objects.filter(target_date__isnull=False).filter(status__lt=PurchaseOrderStatus.COMPLETE.value)) + number_orders = len( + models.PurchaseOrder.objects.filter(target_date__isnull=False).filter( + status__lt=PurchaseOrderStatus.COMPLETE.value + ) + ) # Transform content to a Calendar object calendar = Calendar.from_ical(response.content) @@ -572,9 +556,13 @@ class PurchaseOrderTest(OrderTest): self.assertEqual(number_orders, n_events) # Test with completed orders - response = self.get(url, data={'include_completed': 'True'}, expected_code=200, format=None) + response = self.get( + url, data={'include_completed': 'True'}, expected_code=200, format=None + ) - number_orders_incl_completed = len(models.PurchaseOrder.objects.filter(target_date__isnull=False)) + number_orders_incl_completed = len( + models.PurchaseOrder.objects.filter(target_date__isnull=False) + ) self.assertGreater(number_orders_incl_completed, number_orders) @@ -592,21 +580,28 @@ class PurchaseOrderTest(OrderTest): def test_po_calendar_noauth(self): """Test accessing calendar without authorization""" self.client.logout() - response = self.client.get(reverse('api-po-so-calendar', kwargs={'ordertype': 'purchase-order'}), format='json') + response = self.client.get( + reverse('api-po-so-calendar', kwargs={'ordertype': 'purchase-order'}), + format='json', + ) self.assertEqual(response.status_code, 401) resp_dict = response.json() - self.assertEqual(resp_dict['detail'], "Authentication credentials were not provided.") + self.assertEqual( + resp_dict['detail'], "Authentication credentials were not provided." + ) def test_po_calendar_auth(self): """Test accessing calendar with header authorization""" self.client.logout() - base64_token = base64.b64encode(f'{self.username}:{self.password}'.encode('ascii')).decode('ascii') + base64_token = base64.b64encode( + f'{self.username}:{self.password}'.encode('ascii') + ).decode('ascii') response = self.client.get( reverse('api-po-so-calendar', kwargs={'ordertype': 'purchase-order'}), format='json', - HTTP_AUTHORIZATION=f'basic {base64_token}' + HTTP_AUTHORIZATION=f'basic {base64_token}', ) self.assertEqual(response.status_code, 200) @@ -642,13 +637,7 @@ class PurchaseOrderLineItemTest(OrderTest): url = reverse('api-po-line-list') # Try to delete a set of line items via their IDs - self.delete( - url, - { - 'items': [1, 2], - }, - expected_code=204, - ) + self.delete(url, {'items': [1, 2]}, expected_code=204) # We should have 2 less PurchaseOrderLineItems after deletign them self.assertEqual(models.PurchaseOrderLineItem.objects.count(), n - 2) @@ -668,38 +657,28 @@ class PurchaseOrderDownloadTest(OrderTest): 'supplier_reference', ] - excluded_cols = [ - 'metadata', - ] + excluded_cols = ['metadata'] def test_download_wrong_format(self): """Incorrect format should default raise an error.""" url = reverse('api-po-list') with self.assertRaises(ValueError): - self.download_file( - url, - { - 'export': 'xyz', - } - ) + self.download_file(url, {'export': 'xyz'}) def test_download_csv(self): """Download PurchaseOrder data as .csv.""" with self.download_file( reverse('api-po-list'), - { - 'export': 'csv', - }, + {'export': 'csv'}, expected_code=200, expected_fn='InvenTree_PurchaseOrders.csv', ) as file: - data = self.process_csv( file, required_cols=self.required_cols, excluded_cols=self.excluded_cols, - required_rows=models.PurchaseOrder.objects.count() + required_rows=models.PurchaseOrder.objects.count(), ) for row in data: @@ -712,14 +691,11 @@ class PurchaseOrderDownloadTest(OrderTest): """Test that the PurchaseOrderLineItems can be downloaded to a file""" with self.download_file( reverse('api-po-line-list'), - { - 'export': 'xlsx', - }, + {'export': 'xlsx'}, decode=False, expected_code=200, expected_fn='InvenTree_PurchaseOrderItems.xlsx', ) as file: - self.assertTrue(isinstance(file, io.BytesIO)) @@ -755,12 +731,7 @@ class PurchaseOrderReceiveTest(OrderTest): def test_no_items(self): """Test with an empty list of items.""" data = self.post( - self.url, - { - "items": [], - "location": None, - }, - expected_code=400 + self.url, {"items": [], "location": None}, expected_code=400 ).data self.assertIn('Line items must be provided', str(data)) @@ -772,15 +743,8 @@ class PurchaseOrderReceiveTest(OrderTest): """Test than errors are returned as expected for invalid data.""" data = self.post( self.url, - { - "items": [ - { - "line_item": 12345, - "location": 12345 - } - ] - }, - expected_code=400 + {"items": [{"line_item": 12345, "location": 12345}]}, + expected_code=400, ).data items = data['items'][0] @@ -797,15 +761,10 @@ class PurchaseOrderReceiveTest(OrderTest): self.url, { "items": [ - { - "line_item": 22, - "location": 1, - "status": 99999, - "quantity": 5, - } + {"line_item": 22, "location": 1, "status": 99999, "quantity": 5} ] }, - expected_code=400 + expected_code=400, ).data self.assertIn('"99999" is not a valid choice.', str(data)) @@ -818,16 +777,10 @@ class PurchaseOrderReceiveTest(OrderTest): data = self.post( self.url, { - 'items': [ - { - 'line_item': 22, - 'quantity': 123, - 'location': 1, - } - ], + 'items': [{'line_item': 22, 'quantity': 123, 'location': 1}], 'location': None, }, - expected_code=400 + expected_code=400, ).data self.assertIn('Line item does not match purchase order', str(data)) @@ -845,16 +798,10 @@ class PurchaseOrderReceiveTest(OrderTest): self.post( self.url, { - 'items': [ - { - 'line_item': 1, - 'quantity': 50, - 'barcode': None, - } - ], + 'items': [{'line_item': 1, 'quantity': 50, 'barcode': None}], 'location': 1, }, - expected_code=201 + expected_code=201, ) def test_invalid_barcodes(self): @@ -871,15 +818,11 @@ class PurchaseOrderReceiveTest(OrderTest): self.url, { 'items': [ - { - 'line_item': 1, - 'quantity': 50, - 'barcode': 'MY-BARCODE-HASH', - } + {'line_item': 1, 'quantity': 50, 'barcode': 'MY-BARCODE-HASH'} ], 'location': 1, }, - expected_code=400 + expected_code=400, ) self.assertIn('Barcode is already in use', str(response.data)) @@ -888,20 +831,12 @@ class PurchaseOrderReceiveTest(OrderTest): self.url, { 'items': [ - { - 'line_item': 1, - 'quantity': 5, - 'barcode': 'MY-BARCODE-HASH-1', - }, - { - 'line_item': 1, - 'quantity': 5, - 'barcode': 'MY-BARCODE-HASH-1' - }, + {'line_item': 1, 'quantity': 5, 'barcode': 'MY-BARCODE-HASH-1'}, + {'line_item': 1, 'quantity': 5, 'barcode': 'MY-BARCODE-HASH-1'}, ], 'location': 1, }, - expected_code=400 + expected_code=400, ) self.assertIn('barcode values must be unique', str(response.data)) @@ -922,17 +857,13 @@ class PurchaseOrderReceiveTest(OrderTest): valid_data = { 'items': [ - { - 'line_item': 1, - 'quantity': 50, - 'barcode': 'MY-UNIQUE-BARCODE-123', - }, + {'line_item': 1, 'quantity': 50, 'barcode': 'MY-UNIQUE-BARCODE-123'}, { 'line_item': 2, 'quantity': 200, 'location': 2, # Explicit location 'barcode': 'MY-UNIQUE-BARCODE-456', - } + }, ], 'location': 1, # Default location } @@ -943,11 +874,7 @@ class PurchaseOrderReceiveTest(OrderTest): order.status = PurchaseOrderStatus.PENDING.value order.save() - response = self.post( - self.url, - valid_data, - expected_code=400 - ) + response = self.post(self.url, valid_data, expected_code=400) self.assertIn('can only be received against', str(response.data)) @@ -956,11 +883,7 @@ class PurchaseOrderReceiveTest(OrderTest): order.save() # Receive two separate line items against this order - self.post( - self.url, - valid_data, - expected_code=201, - ) + self.post(self.url, valid_data, expected_code=201) # There should be two newly created stock items self.assertEqual(self.n + 2, StockItem.objects.count()) @@ -983,8 +906,12 @@ class PurchaseOrderReceiveTest(OrderTest): self.assertEqual(stock_2.last().location.pk, 1) # Barcodes should have been assigned to the stock items - self.assertTrue(StockItem.objects.filter(barcode_data='MY-UNIQUE-BARCODE-123').exists()) - self.assertTrue(StockItem.objects.filter(barcode_data='MY-UNIQUE-BARCODE-456').exists()) + self.assertTrue( + StockItem.objects.filter(barcode_data='MY-UNIQUE-BARCODE-123').exists() + ) + self.assertTrue( + StockItem.objects.filter(barcode_data='MY-UNIQUE-BARCODE-456').exists() + ) def test_batch_code(self): """Test that we can supply a 'batch code' when receiving items.""" @@ -996,27 +923,15 @@ class PurchaseOrderReceiveTest(OrderTest): data = { 'items': [ - { - 'line_item': 1, - 'quantity': 10, - 'batch_code': 'B-abc-123', - }, - { - 'line_item': 2, - 'quantity': 10, - 'batch_code': 'B-xyz-789', - } + {'line_item': 1, 'quantity': 10, 'batch_code': 'B-abc-123'}, + {'line_item': 2, 'quantity': 10, 'batch_code': 'B-xyz-789'}, ], 'location': 1, } n = StockItem.objects.count() - self.post( - self.url, - data, - expected_code=201, - ) + self.post(self.url, data, expected_code=201) # Check that two new stock items have been created! self.assertEqual(n + 2, StockItem.objects.count()) @@ -1043,28 +958,22 @@ class PurchaseOrderReceiveTest(OrderTest): 'batch_code': 'B-abc-123', 'serial_numbers': '100+', }, - { - 'line_item': 2, - 'quantity': 10, - 'batch_code': 'B-xyz-789', - } + {'line_item': 2, 'quantity': 10, 'batch_code': 'B-xyz-789'}, ], 'location': 1, } n = StockItem.objects.count() - self.post( - self.url, - data, - expected_code=201, - ) + self.post(self.url, data, expected_code=201) # Check that the expected number of stock items has been created self.assertEqual(n + 11, StockItem.objects.count()) # 10 serialized stock items created for the first line item - self.assertEqual(StockItem.objects.filter(supplier_part=line_1.part).count(), 10) + self.assertEqual( + StockItem.objects.filter(supplier_part=line_1.part).count(), 10 + ) # Check that the correct serial numbers have been allocated for i in range(100, 110): @@ -1129,10 +1038,7 @@ class SalesOrderTest(OrderTest): for customer in Company.objects.filter(is_customer=True): for _idx in range(10): new_orders.append( - models.SalesOrder( - customer=customer, - reference=f'SO-{idx + 100}', - ) + models.SalesOrder(customer=customer, reference=f'SO-{idx + 100}') ) idx += 1 @@ -1153,7 +1059,7 @@ class SalesOrderTest(OrderTest): order=so, part=p, quantity=idx + 1, - sale_price=Money((idx + 1) / 5, currencies[idx % n]) + sale_price=Money((idx + 1) / 5, currencies[idx % n]), ) ) @@ -1163,9 +1069,7 @@ class SalesOrderTest(OrderTest): for _ in range(3): extra_lines.append( models.SalesOrderExtraLine( - order=so, - quantity=(idx + 2) % 10, - price=Money(10, 'CAD'), + order=so, quantity=(idx + 2) % 10, price=Money(10, 'CAD') ) ) @@ -1175,7 +1079,9 @@ class SalesOrderTest(OrderTest): # List all SalesOrder objects and count queries for limit in [1, 5, 10, 100]: with CaptureQueriesContext(connection) as ctx: - response = self.get(self.LIST_URL, data={'limit': limit}, expected_code=200) + response = self.get( + self.LIST_URL, data={'limit': limit}, expected_code=200 + ) # Total database queries must be less than 15 self.assertLess(len(ctx), 15) @@ -1225,11 +1131,7 @@ class SalesOrderTest(OrderTest): # so this POST request should return 403 (denied) response = self.post( url, - { - 'customer': 4, - 'reference': '12345', - 'description': 'Sales order', - }, + {'customer': 4, 'reference': '12345', 'description': 'Sales order'}, expected_code=403, ) @@ -1238,12 +1140,8 @@ class SalesOrderTest(OrderTest): # Now we should be able to create a SalesOrder via the API response = self.post( url, - { - 'customer': 4, - 'reference': 'SO-12345', - 'description': 'Sales order', - }, - expected_code=201 + {'customer': 4, 'reference': 'SO-12345', 'description': 'Sales order'}, + expected_code=201, ) # Check that the new order has been created @@ -1260,7 +1158,7 @@ class SalesOrderTest(OrderTest): 'reference': 'SO-12345', 'description': 'Another sales order', }, - expected_code=400 + expected_code=400, ) url = reverse('api-so-detail', kwargs={'pk': pk}) @@ -1271,21 +1169,9 @@ class SalesOrderTest(OrderTest): # Try to alter (edit) the SalesOrder # Initially try with an invalid reference field value - response = self.patch( - url, - { - 'reference': 'SO-12345-a', - }, - expected_code=400 - ) + response = self.patch(url, {'reference': 'SO-12345-a'}, expected_code=400) - response = self.patch( - url, - { - 'reference': 'SO-12346', - }, - expected_code=200 - ) + response = self.patch(url, {'reference': 'SO-12346'}, expected_code=200) # Reference should have changed self.assertEqual(response.data['reference'], 'SO-12346') @@ -1321,7 +1207,9 @@ class SalesOrderTest(OrderTest): expected_code=400, ) - self.assertIn('Reference must match required pattern', str(response.data['reference'])) + self.assertIn( + 'Reference must match required pattern', str(response.data['reference']) + ) self.post( url, @@ -1366,15 +1254,19 @@ class SalesOrderTest(OrderTest): 'description': f'Calendar SO {i}', 'target_date': f'2024-12-{i:02d}', }, - expected_code=201 + expected_code=201, ) # Cancel a few orders - these will not show in incomplete view below for so in models.SalesOrder.objects.filter(target_date__isnull=False): - if so.reference in ['SO-11000006', 'SO-11000007', 'SO-11000008', 'SO-11000009']: + if so.reference in [ + 'SO-11000006', + 'SO-11000007', + 'SO-11000008', + 'SO-11000009', + ]: self.post( - reverse('api-so-cancel', kwargs={'pk': so.pk}), - expected_code=201 + reverse('api-so-cancel', kwargs={'pk': so.pk}), expected_code=201 ) url = reverse('api-po-so-calendar', kwargs={'ordertype': 'sales-order'}) @@ -1382,7 +1274,11 @@ class SalesOrderTest(OrderTest): # Test without completed orders response = self.get(url, expected_code=200, format=None) - number_orders = len(models.SalesOrder.objects.filter(target_date__isnull=False).filter(status__lt=SalesOrderStatus.SHIPPED.value)) + number_orders = len( + models.SalesOrder.objects.filter(target_date__isnull=False).filter( + status__lt=SalesOrderStatus.SHIPPED.value + ) + ) # Transform content to a Calendar object calendar = Calendar.from_ical(response.content) @@ -1396,9 +1292,13 @@ class SalesOrderTest(OrderTest): self.assertEqual(number_orders, n_events) # Test with completed orders - response = self.get(url, data={'include_completed': 'True'}, expected_code=200, format=None) + response = self.get( + url, data={'include_completed': 'True'}, expected_code=200, format=None + ) - number_orders_incl_complete = len(models.SalesOrder.objects.filter(target_date__isnull=False)) + number_orders_incl_complete = len( + models.SalesOrder.objects.filter(target_date__isnull=False) + ) self.assertGreater(number_orders_incl_complete, number_orders) # Transform content to a Calendar object @@ -1430,7 +1330,7 @@ class SalesOrderTest(OrderTest): {'export': fmt}, decode=True if fmt == 'csv' else False, expected_code=200, - expected_fn=f"InvenTree_SalesOrders.{fmt}" + expected_fn=f"InvenTree_SalesOrders.{fmt}", ) @@ -1451,7 +1351,6 @@ class SalesOrderLineItemTest(OrderTest): # Create a bunch of SalesOrderLineItems for each order for idx, so in enumerate(models.SalesOrder.objects.all()): - for part in parts: lines.append( models.SalesOrderLineItem( @@ -1469,11 +1368,7 @@ class SalesOrderLineItemTest(OrderTest): def test_so_line_list(self): """Test list endpoint""" - response = self.get( - self.url, - {}, - expected_code=200, - ) + response = self.get(self.url, {}, expected_code=200) n = models.SalesOrderLineItem.objects.count() @@ -1481,13 +1376,7 @@ class SalesOrderLineItemTest(OrderTest): self.assertEqual(len(response.data), n) # List *all* lines, but paginate - response = self.get( - self.url, - { - "limit": 5, - }, - expected_code=200, - ) + response = self.get(self.url, {"limit": 5}, expected_code=200) self.assertEqual(response.data['count'], n) self.assertEqual(len(response.data['results']), 5) @@ -1497,25 +1386,13 @@ class SalesOrderLineItemTest(OrderTest): # List by part for part in Part.objects.filter(salable=True)[:3]: - response = self.get( - self.url, - { - 'part': part.pk, - 'limit': 10, - } - ) + response = self.get(self.url, {'part': part.pk, 'limit': 10}) self.assertEqual(response.data['count'], n_orders) # List by order for order in models.SalesOrder.objects.all()[:3]: - response = self.get( - self.url, - { - 'order': order.pk, - 'limit': 10, - } - ) + response = self.get(self.url, {'order': order.pk, 'limit': 10}) self.assertEqual(response.data['count'], n_parts) @@ -1545,9 +1422,7 @@ class SalesOrderDownloadTest(OrderTest): # Download .xls file with self.download_file( url, - { - 'export': 'xls', - }, + {'export': 'xls'}, expected_code=200, expected_fn='InvenTree_SalesOrders.xls', decode=False, @@ -1569,30 +1444,24 @@ class SalesOrderDownloadTest(OrderTest): 'description', ] - excluded_cols = [ - 'metadata' - ] + excluded_cols = ['metadata'] # Download .xls file with self.download_file( url, - { - 'export': 'csv', - }, + {'export': 'csv'}, expected_code=200, expected_fn='InvenTree_SalesOrders.csv', - decode=True + decode=True, ) as file: - data = self.process_csv( file, required_cols=required_cols, excluded_cols=excluded_cols, - required_rows=models.SalesOrder.objects.count() + required_rows=models.SalesOrder.objects.count(), ) for line in data: - order = models.SalesOrder.objects.get(pk=line['id']) self.assertEqual(line['description'], order.description) @@ -1601,20 +1470,18 @@ class SalesOrderDownloadTest(OrderTest): # Download only outstanding sales orders with self.download_file( url, - { - 'export': 'tsv', - 'outstanding': True, - }, + {'export': 'tsv', 'outstanding': True}, expected_code=200, expected_fn='InvenTree_SalesOrders.tsv', decode=True, ) as file: - self.process_csv( file, required_cols=required_cols, excluded_cols=excluded_cols, - required_rows=models.SalesOrder.objects.filter(status__in=SalesOrderStatusGroups.OPEN).count(), + required_rows=models.SalesOrder.objects.filter( + status__in=SalesOrderStatusGroups.OPEN + ).count(), delimiter='\t', ) @@ -1636,24 +1503,16 @@ class SalesOrderAllocateTest(OrderTest): parts = Part.objects.filter(salable=True) for part in parts: - # Create a new line item models.SalesOrderLineItem.objects.create( - order=self.order, - part=part, - quantity=5, + order=self.order, part=part, quantity=5 ) # Ensure we have stock! - StockItem.objects.create( - part=part, - quantity=100, - ) + StockItem.objects.create(part=part, quantity=100) # Create a new shipment against this SalesOrder - self.shipment = models.SalesOrderShipment.objects.create( - order=self.order, - ) + self.shipment = models.SalesOrderShipment.objects.create(order=self.order) def test_invalid(self): """Test POST with invalid data.""" @@ -1669,11 +1528,13 @@ class SalesOrderAllocateTest(OrderTest): # Valid stock_item, but quantity is invalid data = { - 'items': [{ - "line_item": line.pk, - "stock_item": part.stock_items.last().pk, - "quantity": 0, - }], + 'items': [ + { + "line_item": line.pk, + "stock_item": part.stock_items.last().pk, + "quantity": 0, + } + ] } response = self.post(self.url, data, expected_code=400) @@ -1699,24 +1560,23 @@ class SalesOrderAllocateTest(OrderTest): # Valid shipment, but points to the wrong order shipment = models.SalesOrderShipment.objects.create( - order=models.SalesOrder.objects.get(pk=2), + order=models.SalesOrder.objects.get(pk=2) ) data['shipment'] = shipment.pk response = self.post(self.url, data, expected_code=400) - self.assertIn('Shipment is not associated with this order', str(response.data['shipment'])) + self.assertIn( + 'Shipment is not associated with this order', str(response.data['shipment']) + ) def test_allocate(self): """Test that the allocation endpoint acts as expected, when provided with valid data!""" # First, check that there are no line items allocated against this SalesOrder self.assertEqual(self.order.stock_allocations.count(), 0) - data = { - "items": [], - "shipment": self.shipment.pk, - } + data = {"items": [], "shipment": self.shipment.pk} for line in self.order.lines.all(): stock_item = line.part.stock_items.last() @@ -1725,7 +1585,7 @@ class SalesOrderAllocateTest(OrderTest): data['items'].append({ "line_item": line.pk, "stock_item": stock_item.pk, - "quantity": 5 + "quantity": 5, }) self.post(self.url, data, expected_code=201) @@ -1743,16 +1603,12 @@ class SalesOrderAllocateTest(OrderTest): # First, check that there are no line items allocated against this SalesOrder self.assertEqual(self.order.stock_allocations.count(), 0) - data = { - "items": [], - "shipment": self.shipment.pk, - } + data = {"items": [], "shipment": self.shipment.pk} def check_template(line_item): return line_item.part.is_template for line in filter(check_template, self.order.lines.all()): - stock_item = None # Allocate a matching variant @@ -1765,7 +1621,7 @@ class SalesOrderAllocateTest(OrderTest): data['items'].append({ "line_item": line.pk, "stock_item": stock_item.pk, - "quantity": 5 + "quantity": 5, }) self.post(self.url, data, expected_code=201) @@ -1786,10 +1642,7 @@ class SalesOrderAllocateTest(OrderTest): self.shipment.check_can_complete() # Attempting to complete this shipment via the API should fail - response = self.post( - url, {}, - expected_code=400 - ) + response = self.post(url, {}, expected_code=400) self.assertIn('Shipment has no allocated stock items', str(response.data)) @@ -1798,23 +1651,14 @@ class SalesOrderAllocateTest(OrderTest): part = line.part models.SalesOrderAllocation.objects.create( - shipment=self.shipment, - line=line, - item=part.stock_items.last(), - quantity=5 + shipment=self.shipment, line=line, item=part.stock_items.last(), quantity=5 ) # Shipment should now be able to be completed self.assertTrue(self.shipment.check_can_complete()) # Attempt with an invalid date - response = self.post( - url, - { - 'shipment_date': 'asfasd', - }, - expected_code=400, - ) + response = self.post(url, {'shipment_date': 'asfasd'}, expected_code=400) self.assertIn('Date has wrong format', str(response.data)) @@ -1844,35 +1688,17 @@ class SalesOrderAllocateTest(OrderTest): url = reverse('api-so-shipment-detail', kwargs={'pk': self.shipment.pk}) # Attempt remove delivery_date from shipment - response = self.patch( - url, - { - 'delivery_date': None, - }, - expected_code=200, - ) + response = self.patch(url, {'delivery_date': None}, expected_code=200) # Shipment should not be marked as delivered self.assertFalse(self.shipment.is_delivered()) # Attempt to set delivery date - response = self.patch( - url, - { - 'delivery_date': 'asfasd', - }, - expected_code=400, - ) + response = self.patch(url, {'delivery_date': 'asfasd'}, expected_code=400) self.assertIn('Date has wrong format', str(response.data)) - response = self.patch( - url, - { - 'delivery_date': '2023-05-15', - }, - expected_code=200, - ) + response = self.patch(url, {'delivery_date': '2023-05-15'}, expected_code=200) self.shipment.refresh_from_db() # Shipment should now be marked as delivered @@ -1888,26 +1714,19 @@ class SalesOrderAllocateTest(OrderTest): # Create some new shipments via the API for order in models.SalesOrder.objects.all(): - for idx in range(3): self.post( url, { 'order': order.pk, 'reference': f"SH{idx + 1}", - 'tracking_number': f"TRK_{order.pk}_{idx}" + 'tracking_number': f"TRK_{order.pk}_{idx}", }, - expected_code=201 + expected_code=201, ) # Filter API by order - response = self.get( - url, - { - 'order': order.pk, - }, - expected_code=200, - ) + response = self.get(url, {'order': order.pk}, expected_code=200) # 3 shipments returned for each SalesOrder instance self.assertGreaterEqual(len(response.data), 3) @@ -1915,7 +1734,9 @@ class SalesOrderAllocateTest(OrderTest): # List *all* shipments response = self.get(url, expected_code=200) - self.assertEqual(len(response.data), count_before + 3 * models.SalesOrder.objects.count()) + self.assertEqual( + len(response.data), count_before + 3 * models.SalesOrder.objects.count() + ) class ReturnOrderTests(InvenTreeAPITestCase): @@ -1959,12 +1780,8 @@ class ReturnOrderTests(InvenTreeAPITestCase): # Paginated query data = self.get( url, - { - 'limit': 1, - 'ordering': 'reference', - 'customer_detail': True, - }, - expected_code=200 + {'limit': 1, 'ordering': 'reference', 'customer_detail': True}, + expected_code=200, ).data self.assertEqual(data['count'], 6) @@ -1974,25 +1791,13 @@ class ReturnOrderTests(InvenTreeAPITestCase): self.assertEqual(result['customer_detail']['name'], 'A customer') # Reverse ordering - data = self.get( - url, - { - 'ordering': '-reference', - }, - expected_code=200 - ).data + data = self.get(url, {'ordering': '-reference'}, expected_code=200).data self.assertEqual(data[0]['reference'], 'RMA-006') # Filter by customer for cmp_id in [4, 5]: - data = self.get( - url, - { - 'customer': cmp_id, - }, - expected_code=200 - ).data + data = self.get(url, {'customer': cmp_id}, expected_code=200).data self.assertEqual(len(data), 3) @@ -2000,13 +1805,7 @@ class ReturnOrderTests(InvenTreeAPITestCase): self.assertEqual(result['customer'], cmp_id) # Filter by status - data = self.get( - url, - { - 'status': 20, - }, - expected_code=200 - ).data + data = self.get(url, {'status': 20}, expected_code=200).data self.assertEqual(len(data), 2) @@ -2019,12 +1818,7 @@ class ReturnOrderTests(InvenTreeAPITestCase): # Do not have required permissions yet self.post( - url, - { - 'customer': 1, - 'description': 'a return order', - }, - expected_code=403 + url, {'customer': 1, 'description': 'a return order'}, expected_code=403 ) self.assignRole('return_order.add') @@ -2036,7 +1830,7 @@ class ReturnOrderTests(InvenTreeAPITestCase): 'customer_reference': 'cr', 'description': 'a return order', }, - expected_code=201 + expected_code=201, ).data # Reference automatically generated @@ -2054,22 +1848,12 @@ class ReturnOrderTests(InvenTreeAPITestCase): # Attempt to update, incorrect permissions self.patch( - url, - { - 'customer_reference': 'My customer reference', - }, - expected_code=403 + url, {'customer_reference': 'My customer reference'}, expected_code=403 ) self.assignRole('return_order.change') - self.patch( - url, - { - 'customer_reference': 'customer ref', - }, - expected_code=200 - ) + self.patch(url, {'customer_reference': 'customer ref'}, expected_code=200) rma = models.ReturnOrder.objects.get(pk=1) self.assertEqual(rma.customer_reference, 'customer ref') @@ -2098,8 +1882,7 @@ class ReturnOrderTests(InvenTreeAPITestCase): # Create an order rma = models.ReturnOrder.objects.create( - customer=customer, - description='A return order', + customer=customer, description='A return order' ) self.assertEqual(rma.reference, 'RMA-0007') @@ -2108,13 +1891,11 @@ class ReturnOrderTests(InvenTreeAPITestCase): part = Part.objects.get(pk=25) for idx in range(3): stock_item = StockItem.objects.create( - part=part, customer=customer, - quantity=1, serial=idx + part=part, customer=customer, quantity=1, serial=idx ) line_item = models.ReturnOrderLineItem.objects.create( - order=rma, - item=stock_item, + order=rma, item=stock_item ) self.assertEqual(line_item.outcome, ReturnOrderLineStatus.PENDING) @@ -2128,12 +1909,7 @@ class ReturnOrderTests(InvenTreeAPITestCase): url = reverse('api-return-order-receive', kwargs={'pk': rma.pk}) response = self.post( - url, - { - 'items': items, - 'location': location, - }, - expected_code=expected_code + url, {'items': items, 'location': location}, expected_code=expected_code ) return response.data @@ -2149,13 +1925,14 @@ class ReturnOrderTests(InvenTreeAPITestCase): # Receive, with incorrect order code data = receive([], 1, expected_code=400) - self.assertIn('Items can only be received against orders which are in progress', str(data)) + self.assertIn( + 'Items can only be received against orders which are in progress', str(data) + ) # Issue the order (via the API) self.assertIsNone(rma.issue_date) self.post( - reverse("api-return-order-issue", kwargs={"pk": rma.pk}), - expected_code=201, + reverse("api-return-order-issue", kwargs={"pk": rma.pk}), expected_code=201 ) rma.refresh_from_db() @@ -2173,9 +1950,7 @@ class ReturnOrderTests(InvenTreeAPITestCase): # Receive items successfully data = receive( - [{'item': line.pk} for line in rma.lines.all()], - 1, - expected_code=201 + [{'item': line.pk} for line in rma.lines.all()], 1, expected_code=201 ) # Check that all line items have been received @@ -2221,11 +1996,7 @@ class OrderMetadataAPITest(InvenTreeAPITestCase): 'return_order', ] - roles = [ - 'purchase_order.change', - 'sales_order.change', - 'return_order.change', - ] + roles = ['purchase_order.change', 'sales_order.change', 'return_order.change'] def metatester(self, apikey, model): """Generic tester""" @@ -2243,17 +2014,15 @@ class OrderMetadataAPITest(InvenTreeAPITestCase): self.patch( url, - { - 'metadata': { - f'abc-{numstr}': f'xyz-{apikey}-{numstr}', - } - }, - expected_code=200 + {'metadata': {f'abc-{numstr}': f'xyz-{apikey}-{numstr}'}}, + expected_code=200, ) # Refresh modeldata.refresh_from_db() - self.assertEqual(modeldata.get_metadata(f'abc-{numstr}'), f'xyz-{apikey}-{numstr}') + self.assertEqual( + modeldata.get_metadata(f'abc-{numstr}'), f'xyz-{apikey}-{numstr}' + ) def test_metadata(self): """Test all endpoints""" diff --git a/InvenTree/order/test_migrations.py b/InvenTree/order/test_migrations.py index 8bcdbeae8e..b55d572939 100644 --- a/InvenTree/order/test_migrations.py +++ b/InvenTree/order/test_migrations.py @@ -28,11 +28,10 @@ class TestRefIntMigrations(MigratorTestCase): # Create some orders for ii in range(10): - order = PurchaseOrder.objects.create( supplier=supplier, reference=f"{ii}-abcde", - description="Just a test order" + description="Just a test order", ) # Initially, the 'reference_int' field is unavailable @@ -68,7 +67,6 @@ class TestRefIntMigrations(MigratorTestCase): SalesOrder = self.new_state.apps.get_model('order', 'salesorder') for ii in range(10): - po = PurchaseOrder.objects.get(reference=f"{ii}-abcde") so = SalesOrder.objects.get(reference=f"{ii}-xyz") @@ -79,11 +77,11 @@ class TestRefIntMigrations(MigratorTestCase): # Tests for orders with overly large reference values po = PurchaseOrder.objects.get(pk=self.po_pk) self.assertEqual(po.reference, '999999999999999999999999999999999') - self.assertEqual(po.reference_int, 0x7fffffff) + self.assertEqual(po.reference_int, 0x7FFFFFFF) so = SalesOrder.objects.get(pk=self.so_pk) self.assertEqual(so.reference, '999999999999999999999999999999999') - self.assertEqual(so.reference_int, 0x7fffffff) + self.assertEqual(so.reference_int, 0x7FFFFFFF) class TestShipmentMigration(MigratorTestCase): @@ -99,7 +97,7 @@ class TestShipmentMigration(MigratorTestCase): customer = Company.objects.create( name='My customer', description='A customer we sell stuff too', - is_customer=True + is_customer=True, ) SalesOrder = self.old_state.apps.get_model('order', 'salesorder') @@ -162,28 +160,17 @@ class TestAdditionalLineMigration(MigratorTestCase): lft=0, rght=0, ) - supplierpart = Supplierpart.objects.create( - part=part, - supplier=supplier - ) + supplierpart = Supplierpart.objects.create(part=part, supplier=supplier) # Create some orders for ii in range(10): - order = PurchaseOrder.objects.create( supplier=supplier, reference=f"{ii}-abcde", - description="Just a test order" - ) - order.lines.create( - part=supplierpart, - quantity=12, - received=1 - ) - order.lines.create( - quantity=12, - received=1 + description="Just a test order", ) + order.lines.create(part=supplierpart, quantity=12, received=1) + order.lines.create(quantity=12, received=1) # TODO @matmair fix this test!!! # sales_order = SalesOrder.objects.create( @@ -201,7 +188,6 @@ class TestAdditionalLineMigration(MigratorTestCase): """Test that the the PO lines where converted correctly.""" PurchaseOrder = self.new_state.apps.get_model('order', 'purchaseorder') for ii in range(10): - po = PurchaseOrder.objects.get(reference=f"{ii}-abcde") self.assertEqual(po.extra_lines.count(), 1) self.assertEqual(po.lines.count(), 1) @@ -209,6 +195,6 @@ class TestAdditionalLineMigration(MigratorTestCase): # TODO @matmair fix this test!!! # SalesOrder = self.new_state.apps.get_model('order', 'salesorder') # for ii in range(10): - # so = SalesOrder.objects.get(reference=f"{ii}-xyz") - # self.assertEqual(so.extra_lines, 1) - # self.assertEqual(so.lines.count(), 1) + # so = SalesOrder.objects.get(reference=f"{ii}-xyz") + # self.assertEqual(so.extra_lines, 1) + # self.assertEqual(so.lines.count(), 1) diff --git a/InvenTree/order/test_sales_order.py b/InvenTree/order/test_sales_order.py index 3bbf33c46c..fe20b2c10c 100644 --- a/InvenTree/order/test_sales_order.py +++ b/InvenTree/order/test_sales_order.py @@ -11,9 +11,13 @@ import order.tasks from common.models import InvenTreeSetting, NotificationMessage from company.models import Company from InvenTree import status_codes as status -from order.models import (SalesOrder, SalesOrderAllocation, - SalesOrderExtraLine, SalesOrderLineItem, - SalesOrderShipment) +from order.models import ( + SalesOrder, + SalesOrderAllocation, + SalesOrderExtraLine, + SalesOrderLineItem, + SalesOrderShipment, +) from part.models import Part from stock.models import StockItem from users.models import Owner @@ -22,15 +26,15 @@ from users.models import Owner class SalesOrderTest(TestCase): """Run tests to ensure that the SalesOrder model is working correctly.""" - fixtures = [ - 'users', - ] + fixtures = ['users'] @classmethod def setUpTestData(cls): """Initial setup for this set of unit tests""" # Create a Company to ship the goods to - cls.customer = Company.objects.create(name="ABC Co", description="My customer", is_customer=True) + cls.customer = Company.objects.create( + name="ABC Co", description="My customer", is_customer=True + ) # Create a Part to ship cls.part = Part.objects.create( @@ -53,22 +57,23 @@ class SalesOrderTest(TestCase): # Create a SalesOrder to ship against cls.order = SalesOrder.objects.create( - customer=cls.customer, - reference='SO-1234', - customer_reference='ABC 55555' + customer=cls.customer, reference='SO-1234', customer_reference='ABC 55555' ) # Create a Shipment against this SalesOrder cls.shipment = SalesOrderShipment.objects.create( - order=cls.order, - reference='SO-001', + order=cls.order, reference='SO-001' ) # Create a line item - cls.line = SalesOrderLineItem.objects.create(quantity=50, order=cls.order, part=cls.part) + cls.line = SalesOrderLineItem.objects.create( + quantity=50, order=cls.order, part=cls.part + ) # Create an extra line - cls.extraline = SalesOrderExtraLine.objects.create(quantity=1, order=cls.order, reference="Extra line") + cls.extraline = SalesOrderExtraLine.objects.create( + quantity=1, order=cls.order, reference="Extra line" + ) def test_so_reference(self): """Unit tests for sales order generation""" @@ -120,7 +125,9 @@ class SalesOrderTest(TestCase): def test_add_duplicate_line_item(self): """Adding a duplicate line item to a SalesOrder is accepted""" for ii in range(1, 5): - SalesOrderLineItem.objects.create(order=self.order, part=self.part, quantity=ii) + SalesOrderLineItem.objects.create( + order=self.order, part=self.part, quantity=ii + ) def allocate_stock(self, full=True): """Allocate stock to the order""" @@ -128,13 +135,14 @@ class SalesOrderTest(TestCase): line=self.line, shipment=self.shipment, item=StockItem.objects.get(pk=self.Sa.pk), - quantity=25) + quantity=25, + ) SalesOrderAllocation.objects.create( line=self.line, shipment=self.shipment, item=StockItem.objects.get(pk=self.Sb.pk), - quantity=25 if full else 20 + quantity=25 if full else 20, ) def test_over_allocate(self): @@ -145,10 +153,7 @@ class SalesOrderTest(TestCase): # First three allocations should succeed for _i in range(3): allocation = SalesOrderAllocation.objects.create( - line=self.line, - item=SA, - quantity=3, - shipment=self.shipment + line=self.line, item=SA, quantity=3, shipment=self.shipment ) # Editing an existing allocation with a larger quantity should fail @@ -160,10 +165,7 @@ class SalesOrderTest(TestCase): # Next allocation should fail with self.assertRaises(ValidationError): allocation = SalesOrderAllocation.objects.create( - line=self.line, - item=SA, - quantity=3, - shipment=self.shipment + line=self.line, item=SA, quantity=3, shipment=self.shipment ) allocation.clean() @@ -191,7 +193,7 @@ class SalesOrderTest(TestCase): line=self.line, shipment=self.shipment, item=StockItem.objects.get(pk=self.Sc.pk), - quantity=50 + quantity=50, ) self.assertEqual(self.line.allocated_quantity(), 50) @@ -279,13 +281,13 @@ class SalesOrderTest(TestCase): def test_default_shipment(self): """Test sales order default shipment creation""" # Default setting value should be False - self.assertEqual(False, InvenTreeSetting.get_setting('SALESORDER_DEFAULT_SHIPMENT')) + self.assertEqual( + False, InvenTreeSetting.get_setting('SALESORDER_DEFAULT_SHIPMENT') + ) # Create an order order_1 = SalesOrder.objects.create( - customer=self.customer, - reference='1235', - customer_reference='ABC 55556' + customer=self.customer, reference='1235', customer_reference='ABC 55556' ) # Order should have no shipments when setting is False @@ -293,13 +295,13 @@ class SalesOrderTest(TestCase): # Update setting to True InvenTreeSetting.set_setting('SALESORDER_DEFAULT_SHIPMENT', True, None) - self.assertEqual(True, InvenTreeSetting.get_setting('SALESORDER_DEFAULT_SHIPMENT')) + self.assertEqual( + True, InvenTreeSetting.get_setting('SALESORDER_DEFAULT_SHIPMENT') + ) # Create a second order order_2 = SalesOrder.objects.create( - customer=self.customer, - reference='1236', - customer_reference='ABC 55557' + customer=self.customer, reference='1236', customer_reference='ABC 55557' ) # Order should have one shipment @@ -326,7 +328,7 @@ class SalesOrderTest(TestCase): order.tasks.check_overdue_sales_orders() messages = NotificationMessage.objects.filter( - category='order.overdue_sales_order', + category='order.overdue_sales_order' ) self.assertEqual(len(messages), 1) @@ -341,12 +343,10 @@ class SalesOrderTest(TestCase): customer=self.customer, reference='1234567', created_by=get_user_model().objects.get(pk=3), - responsible=Owner.create(obj=Group.objects.get(pk=3)) + responsible=Owner.create(obj=Group.objects.get(pk=3)), ) - messages = NotificationMessage.objects.filter( - category='order.new_salesorder', - ) + messages = NotificationMessage.objects.filter(category='order.new_salesorder') # A notification should have been generated for user 4 (who is a member of group 3) self.assertTrue(messages.filter(user__pk=4).exists()) @@ -356,7 +356,12 @@ class SalesOrderTest(TestCase): def test_metadata(self): """Unit tests for the metadata field.""" - for model in [SalesOrder, SalesOrderLineItem, SalesOrderExtraLine, SalesOrderShipment]: + for model in [ + SalesOrder, + SalesOrderLineItem, + SalesOrderExtraLine, + SalesOrderShipment, + ]: p = model.objects.first() self.assertIsNone(p.get_metadata('test')) diff --git a/InvenTree/order/test_views.py b/InvenTree/order/test_views.py index bb1e5bddf8..f6ebdf6272 100644 --- a/InvenTree/order/test_views.py +++ b/InvenTree/order/test_views.py @@ -7,6 +7,7 @@ from InvenTree.unit_test import InvenTreeTestCase class OrderViewTestCase(InvenTreeTestCase): """Base unit test class for order views""" + fixtures = [ 'category', 'part', @@ -35,6 +36,7 @@ class OrderViewTestCase(InvenTreeTestCase): class PurchaseOrderListTest(OrderViewTestCase): """Unit tests for the PurchaseOrder index page""" + def test_order_list(self): """Tests for the PurchaseOrder index page""" response = self.client.get(reverse('purchase-order-index')) @@ -54,7 +56,9 @@ class PurchaseOrderTests(OrderViewTestCase): def test_po_export(self): """Export PurchaseOrder.""" - response = self.client.get(reverse('po-export', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') + response = self.client.get( + reverse('po-export', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest' + ) # Response should be streaming-content (file download) self.assertIn('streaming_content', dir(response)) diff --git a/InvenTree/order/tests.py b/InvenTree/order/tests.py index 5b9ee89b0d..cba7904e1f 100644 --- a/InvenTree/order/tests.py +++ b/InvenTree/order/tests.py @@ -18,8 +18,7 @@ from part.models import Part from stock.models import StockItem, StockLocation from users.models import Owner -from .models import (PurchaseOrder, PurchaseOrderExtraLine, - PurchaseOrderLineItem) +from .models import PurchaseOrder, PurchaseOrderExtraLine, PurchaseOrderLineItem class OrderTest(TestCase): @@ -40,7 +39,6 @@ class OrderTest(TestCase): def test_basics(self): """Basic tests e.g. repr functions etc.""" for pk in range(1, 8): - order = PurchaseOrder.objects.get(pk=pk) self.assertEqual(order.get_absolute_url(), f'/order/purchase-order/{pk}/') @@ -222,18 +220,12 @@ class OrderTest(TestCase): # Create a new supplier part with larger pack size sp_1 = SupplierPart.objects.create( - part=prt, - supplier=sup, - SKU='SKUx10', - pack_quantity='10', + part=prt, supplier=sup, SKU='SKUx10', pack_quantity='10' ) # Create a new supplier part with smaller pack size sp_2 = SupplierPart.objects.create( - part=prt, - supplier=sup, - SKU='SKUx0.1', - pack_quantity='0.1', + part=prt, supplier=sup, SKU='SKUx0.1', pack_quantity='0.1' ) # Record values before we start @@ -244,9 +236,7 @@ class OrderTest(TestCase): # Create a new PurchaseOrder po = PurchaseOrder.objects.create( - supplier=sup, - reference=f"PO-{n + 1}", - description='Some PO', + supplier=sup, reference=f"PO-{n + 1}", description='Some PO' ) # Add line items @@ -291,15 +281,11 @@ class OrderTest(TestCase): # The 'on_order' quantity should have decreased by 10.5 self.assertEqual( - prt.on_order, - round(on_order + Decimal(31.3) - Decimal(10.5), 1) + prt.on_order, round(on_order + Decimal(31.3) - Decimal(10.5), 1) ) # The 'in_stock' quantity should have increased by 10.5 - self.assertEqual( - prt.total_stock, - round(in_stock + Decimal(10.5), 1) - ) + self.assertEqual(prt.total_stock, round(in_stock + Decimal(10.5), 1)) # Check that the unit purchase price value has been updated correctly si = StockItem.objects.filter(supplier_part=sp_1) @@ -341,8 +327,7 @@ class OrderTest(TestCase): for user_id in [2, 3, 4]: messages = common.models.NotificationMessage.objects.filter( - category='order.overdue_purchase_order', - user__id=user_id, + category='order.overdue_purchase_order', user__id=user_id ) # User ID 3 is inactive, and thus should not receive notifications @@ -373,7 +358,7 @@ class OrderTest(TestCase): # Initially, no notifications messages = common.models.NotificationMessage.objects.filter( - category='order.new_purchaseorder', + category='order.new_purchaseorder' ) self.assertEqual(messages.count(), 0) diff --git a/InvenTree/order/urls.py b/InvenTree/order/urls.py index 7305bf8543..f1ae8b8446 100644 --- a/InvenTree/order/urls.py +++ b/InvenTree/order/urls.py @@ -9,34 +9,27 @@ from django.urls import include, path, re_path from . import views purchase_order_detail_urls = [ - re_path(r'^upload/', views.PurchaseOrderUpload.as_view(), name='po-upload'), re_path(r'^export/', views.PurchaseOrderExport.as_view(), name='po-export'), - re_path(r'^.*$', views.PurchaseOrderDetail.as_view(), name='po-detail'), ] purchase_order_urls = [ - re_path(r'^pricing/', views.LineItemPricing.as_view(), name='line-pricing'), - # Display detail view for a single purchase order path(r'/', include(purchase_order_detail_urls)), - # Display complete list of purchase orders re_path(r'^.*$', views.PurchaseOrderIndex.as_view(), name='purchase-order-index'), ] sales_order_detail_urls = [ re_path(r'^export/', views.SalesOrderExport.as_view(), name='so-export'), - re_path(r'^.*$', views.SalesOrderDetail.as_view(), name='so-detail'), ] sales_order_urls = [ # Display detail view for a single SalesOrder path(r'/', include(sales_order_detail_urls)), - # Display list of all sales orders re_path(r'^.*$', views.SalesOrderIndex.as_view(), name='sales-order-index'), ] @@ -44,7 +37,6 @@ sales_order_urls = [ return_order_urls = [ path(r'/', views.ReturnOrderDetail.as_view(), name='return-order-detail'), - # Display list of all return orders re_path(r'^.*$', views.ReturnOrderIndex.as_view(), name='return-order-index'), ] diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index 4130a06eaa..49e50141e8 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -24,8 +24,13 @@ from plugin.views import InvenTreePluginViewMixin from . import forms as order_forms from .admin import PurchaseOrderLineItemResource, SalesOrderLineItemResource -from .models import (PurchaseOrder, PurchaseOrderLineItem, ReturnOrder, - SalesOrder, SalesOrderLineItem) +from .models import ( + PurchaseOrder, + PurchaseOrderLineItem, + ReturnOrder, + SalesOrder, + SalesOrderLineItem, +) logger = logging.getLogger("inventree") @@ -46,6 +51,7 @@ class PurchaseOrderIndex(InvenTreeRoleMixin, ListView): class SalesOrderIndex(InvenTreeRoleMixin, ListView): """SalesOrder index (list) view class""" + model = SalesOrder template_name = 'order/sales_orders.html' context_object_name = 'orders' @@ -71,7 +77,9 @@ class SalesOrderDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView) """Detail view for a SalesOrder object.""" context_object_name = 'order' - queryset = SalesOrder.objects.all().prefetch_related('lines__allocations__item__purchase_order') + queryset = SalesOrder.objects.all().prefetch_related( + 'lines__allocations__item__purchase_order' + ) template_name = 'order/sales_order_detail.html' @@ -88,20 +96,12 @@ class PurchaseOrderUpload(FileManagementFormView): class OrderFileManager(FileManager): """Specify required fields""" - REQUIRED_HEADERS = [ - 'Quantity', - ] - ITEM_MATCH_HEADERS = [ - 'Manufacturer_MPN', - 'Supplier_SKU', - ] + REQUIRED_HEADERS = ['Quantity'] - OPTIONAL_HEADERS = [ - 'Purchase_Price', - 'Reference', - 'Notes', - ] + ITEM_MATCH_HEADERS = ['Manufacturer_MPN', 'Supplier_SKU'] + + OPTIONAL_HEADERS = ['Purchase_Price', 'Reference', 'Notes'] name = 'order' form_list = [ @@ -150,7 +150,9 @@ class PurchaseOrderUpload(FileManagementFormView): """ order = self.get_order() - self.allowed_items = SupplierPart.objects.filter(supplier=order.supplier).prefetch_related('manufacturer_part') + self.allowed_items = SupplierPart.objects.filter( + supplier=order.supplier + ).prefetch_related('manufacturer_part') # Fields prefixed with "Part_" can be used to do "smart matching" against Part objects in the database q_idx = self.get_column_index('Quantity') @@ -161,7 +163,6 @@ class PurchaseOrderUpload(FileManagementFormView): n_idx = self.get_column_index('Notes') for row in self.rows: - # Initially use a quantity of zero quantity = Decimal(0) @@ -191,7 +192,11 @@ class PurchaseOrderUpload(FileManagementFormView): try: # Attempt SupplierPart lookup based on SKU value exact_match_part = self.allowed_items.get(SKU__contains=sku) - except (ValueError, SupplierPart.DoesNotExist, SupplierPart.MultipleObjectsReturned): + except ( + ValueError, + SupplierPart.DoesNotExist, + SupplierPart.MultipleObjectsReturned, + ): exact_match_part = None # Check if there is a column corresponding to "Manufacturer MPN" and no exact match found yet @@ -200,8 +205,14 @@ class PurchaseOrderUpload(FileManagementFormView): try: # Attempt SupplierPart lookup based on MPN value - exact_match_part = self.allowed_items.get(manufacturer_part__MPN__contains=mpn) - except (ValueError, SupplierPart.DoesNotExist, SupplierPart.MultipleObjectsReturned): + exact_match_part = self.allowed_items.get( + manufacturer_part__MPN__contains=mpn + ) + except ( + ValueError, + SupplierPart.DoesNotExist, + SupplierPart.MultipleObjectsReturned, + ): exact_match_part = None # Supply list of part options for each row, sorted by how closely they match the part name @@ -239,7 +250,9 @@ class PurchaseOrderUpload(FileManagementFormView): # Create PurchaseOrderLineItem instances for purchase_order_item in items.values(): try: - supplier_part = SupplierPart.objects.get(pk=int(purchase_order_item['part'])) + supplier_part = SupplierPart.objects.get( + pk=int(purchase_order_item['part']) + ) except (ValueError, SupplierPart.DoesNotExist): continue @@ -259,7 +272,9 @@ class PurchaseOrderUpload(FileManagementFormView): # PurchaseOrderLineItem already exists pass - return HttpResponseRedirect(reverse('po-detail', kwargs={'pk': self.kwargs['pk']})) + return HttpResponseRedirect( + reverse('po-detail', kwargs={'pk': self.kwargs['pk']}) + ) class SalesOrderExport(AjaxView): @@ -320,6 +335,7 @@ class LineItemPricing(PartPricing): class EnhancedForm(PartPricing.form_class): """Extra form options""" + pk = IntegerField(widget=HiddenInput()) so_line = IntegerField(widget=HiddenInput()) @@ -401,7 +417,9 @@ class LineItemPricing(PartPricing): # check qunatity and update if different if so_line.quantity != quantity: so_line.quantity = quantity - note = _('Updated {part} unit-price to {price} and quantity to {qty}') + note = _( + 'Updated {part} unit-price to {price} and quantity to {qty}' + ) # update sale_price so_line.sale_price = price @@ -410,7 +428,11 @@ class LineItemPricing(PartPricing): # parse response data = { 'form_valid': True, - 'success': note.format(part=str(so_line.part), price=str(so_line.sale_price), qty=quantity) + 'success': note.format( + part=str(so_line.part), + price=str(so_line.sale_price), + qty=quantity, + ), } return JsonResponse(data=data) diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py index bb0946892e..00c5d6ceee 100644 --- a/InvenTree/part/admin.py +++ b/InvenTree/part/admin.py @@ -18,53 +18,145 @@ class PartResource(InvenTreeResource): class Meta: """Metaclass definition""" + model = models.Part skip_unchanged = True report_skipped = False clean_model_instances = True exclude = [ - 'bom_checksum', 'bom_checked_by', 'bom_checked_date', - 'lft', 'rght', 'tree_id', 'level', + 'bom_checksum', + 'bom_checked_by', + 'bom_checked_date', + 'lft', + 'rght', + 'tree_id', + 'level', 'metadata', - 'barcode_data', 'barcode_hash', + 'barcode_data', + 'barcode_hash', ] id = Field(attribute='pk', column_name=_('Part ID'), widget=widgets.IntegerWidget()) - name = Field(attribute='name', column_name=_('Part Name'), widget=widgets.CharWidget()) - description = Field(attribute='description', column_name=_('Part Description'), widget=widgets.CharWidget()) + name = Field( + attribute='name', column_name=_('Part Name'), widget=widgets.CharWidget() + ) + description = Field( + attribute='description', + column_name=_('Part Description'), + widget=widgets.CharWidget(), + ) IPN = Field(attribute='IPN', column_name=_('IPN'), widget=widgets.CharWidget()) - revision = Field(attribute='revision', column_name=_('Revision'), widget=widgets.CharWidget()) - keywords = Field(attribute='keywords', column_name=_('Keywords'), widget=widgets.CharWidget()) + revision = Field( + attribute='revision', column_name=_('Revision'), widget=widgets.CharWidget() + ) + keywords = Field( + attribute='keywords', column_name=_('Keywords'), widget=widgets.CharWidget() + ) link = Field(attribute='link', column_name=_('Link'), widget=widgets.CharWidget()) - units = Field(attribute='units', column_name=_('Units'), widget=widgets.CharWidget()) + units = Field( + attribute='units', column_name=_('Units'), widget=widgets.CharWidget() + ) notes = Field(attribute='notes', column_name=_('Notes')) image = Field(attribute='image', column_name=_('Part Image'), readonly=True) - category = Field(attribute='category', column_name=_('Category ID'), widget=widgets.ForeignKeyWidget(models.PartCategory)) - category_name = Field(attribute='category__name', column_name=_('Category Name'), readonly=True) - default_location = Field(attribute='default_location', column_name=_('Default Location ID'), widget=widgets.ForeignKeyWidget(StockLocation)) - default_supplier = Field(attribute='default_supplier', column_name=_('Default Supplier ID'), widget=widgets.ForeignKeyWidget(SupplierPart)) - variant_of = Field(attribute='variant_of', column_name=_('Variant Of'), widget=widgets.ForeignKeyWidget(models.Part)) + category = Field( + attribute='category', + column_name=_('Category ID'), + widget=widgets.ForeignKeyWidget(models.PartCategory), + ) + category_name = Field( + attribute='category__name', column_name=_('Category Name'), readonly=True + ) + default_location = Field( + attribute='default_location', + column_name=_('Default Location ID'), + widget=widgets.ForeignKeyWidget(StockLocation), + ) + default_supplier = Field( + attribute='default_supplier', + column_name=_('Default Supplier ID'), + widget=widgets.ForeignKeyWidget(SupplierPart), + ) + variant_of = Field( + attribute='variant_of', + column_name=_('Variant Of'), + widget=widgets.ForeignKeyWidget(models.Part), + ) minimum_stock = Field(attribute='minimum_stock', column_name=_('Minimum Stock')) # Part Attributes - active = Field(attribute='active', column_name=_('Active'), widget=widgets.BooleanWidget()) - assembly = Field(attribute='assembly', column_name=_('Assembly'), widget=widgets.BooleanWidget()) - component = Field(attribute='component', column_name=_('Component'), widget=widgets.BooleanWidget()) - purchaseable = Field(attribute='purchaseable', column_name=_('Purchaseable'), widget=widgets.BooleanWidget()) - salable = Field(attribute='salable', column_name=_('Salable'), widget=widgets.BooleanWidget()) - is_template = Field(attribute='is_template', column_name=_('Template'), widget=widgets.BooleanWidget()) - trackable = Field(attribute='trackable', column_name=_('Trackable'), widget=widgets.BooleanWidget()) - virtual = Field(attribute='virtual', column_name=_('Virtual'), widget=widgets.BooleanWidget()) + active = Field( + attribute='active', column_name=_('Active'), widget=widgets.BooleanWidget() + ) + assembly = Field( + attribute='assembly', column_name=_('Assembly'), widget=widgets.BooleanWidget() + ) + component = Field( + attribute='component', + column_name=_('Component'), + widget=widgets.BooleanWidget(), + ) + purchaseable = Field( + attribute='purchaseable', + column_name=_('Purchaseable'), + widget=widgets.BooleanWidget(), + ) + salable = Field( + attribute='salable', column_name=_('Salable'), widget=widgets.BooleanWidget() + ) + is_template = Field( + attribute='is_template', + column_name=_('Template'), + widget=widgets.BooleanWidget(), + ) + trackable = Field( + attribute='trackable', + column_name=_('Trackable'), + widget=widgets.BooleanWidget(), + ) + virtual = Field( + attribute='virtual', column_name=_('Virtual'), widget=widgets.BooleanWidget() + ) # Extra calculated meta-data (readonly) - suppliers = Field(attribute='supplier_count', column_name=_('Suppliers'), readonly=True) - in_stock = Field(attribute='total_stock', column_name=_('In Stock'), readonly=True, widget=widgets.IntegerWidget()) - on_order = Field(attribute='on_order', column_name=_('On Order'), readonly=True, widget=widgets.IntegerWidget()) - used_in = Field(attribute='used_in_count', column_name=_('Used In'), readonly=True, widget=widgets.IntegerWidget()) - allocated = Field(attribute='allocation_count', column_name=_('Allocated'), readonly=True, widget=widgets.IntegerWidget()) - building = Field(attribute='quantity_being_built', column_name=_('Building'), readonly=True, widget=widgets.IntegerWidget()) - min_cost = Field(attribute='pricing__overall_min', column_name=_('Minimum Cost'), readonly=True) - max_cost = Field(attribute='pricing__overall_max', column_name=_('Maximum Cost'), readonly=True) + suppliers = Field( + attribute='supplier_count', column_name=_('Suppliers'), readonly=True + ) + in_stock = Field( + attribute='total_stock', + column_name=_('In Stock'), + readonly=True, + widget=widgets.IntegerWidget(), + ) + on_order = Field( + attribute='on_order', + column_name=_('On Order'), + readonly=True, + widget=widgets.IntegerWidget(), + ) + used_in = Field( + attribute='used_in_count', + column_name=_('Used In'), + readonly=True, + widget=widgets.IntegerWidget(), + ) + allocated = Field( + attribute='allocation_count', + column_name=_('Allocated'), + readonly=True, + widget=widgets.IntegerWidget(), + ) + building = Field( + attribute='quantity_being_built', + column_name=_('Building'), + readonly=True, + widget=widgets.IntegerWidget(), + ) + min_cost = Field( + attribute='pricing__overall_min', column_name=_('Minimum Cost'), readonly=True + ) + max_cost = Field( + attribute='pricing__overall_max', column_name=_('Maximum Cost'), readonly=True + ) def dehydrate_min_cost(self, part): """Render minimum cost value for this Part""" @@ -88,7 +180,7 @@ class PartResource(InvenTreeResource): 'used_in', 'builds', 'supplier_parts__purchase_order_line_items', - 'stock_items__allocations' + 'stock_items__allocations', ) return query @@ -106,21 +198,33 @@ class PartImportResource(InvenTreeResource): class Meta(PartResource.Meta): """Metaclass definition""" + skip_unchanged = True report_skipped = False clean_model_instances = True exclude = [ - 'id', 'category__name', 'creation_date', 'creation_user', - 'pricing__overall_min', 'pricing__overall_max', - 'bom_checksum', 'bom_checked_by', 'bom_checked_date', - 'lft', 'rght', 'tree_id', 'level', + 'id', + 'category__name', + 'creation_date', + 'creation_user', + 'pricing__overall_min', + 'pricing__overall_max', + 'bom_checksum', + 'bom_checked_by', + 'bom_checked_date', + 'lft', + 'rght', + 'tree_id', + 'level', 'metadata', - 'barcode_data', 'barcode_hash', + 'barcode_data', + 'barcode_hash', ] class PartParameterInline(admin.TabularInline): """Inline for part parameter data""" + model = models.PartParameter @@ -133,7 +237,13 @@ class PartAdmin(ImportExportModelAdmin): list_filter = ('active', 'assembly', 'is_template', 'virtual') - search_fields = ('name', 'description', 'category__name', 'category__description', 'IPN') + search_fields = ( + 'name', + 'description', + 'category__name', + 'category__description', + 'IPN', + ) autocomplete_fields = [ 'variant_of', @@ -142,9 +252,7 @@ class PartAdmin(ImportExportModelAdmin): 'default_supplier', ] - inlines = [ - PartParameterInline, - ] + inlines = [PartParameterInline] class PartPricingAdmin(admin.ModelAdmin): @@ -152,9 +260,7 @@ class PartPricingAdmin(admin.ModelAdmin): list_display = ('part', 'overall_min', 'overall_max') - autcomplete_fields = [ - 'part', - ] + autcomplete_fields = ['part'] class PartStocktakeAdmin(admin.ModelAdmin): @@ -174,6 +280,7 @@ class PartCategoryResource(InvenTreeResource): class Meta: """Metaclass definition""" + model = models.PartCategory skip_unchanged = True report_skipped = False @@ -181,22 +288,42 @@ class PartCategoryResource(InvenTreeResource): exclude = [ # Exclude MPTT internal model fields - 'lft', 'rght', 'tree_id', 'level', + 'lft', + 'rght', + 'tree_id', + 'level', 'metadata', 'icon', ] - id = Field(attribute='pk', column_name=_('Category ID'), widget=widgets.IntegerWidget()) + id = Field( + attribute='pk', column_name=_('Category ID'), widget=widgets.IntegerWidget() + ) name = Field(attribute='name', column_name=_('Category Name')) description = Field(attribute='description', column_name=_('Description')) - parent = Field(attribute='parent', column_name=_('Parent ID'), widget=widgets.ForeignKeyWidget(models.PartCategory)) - parent_name = Field(attribute='parent__name', column_name=_('Parent Name'), readonly=True) - default_location = Field(attribute='default_location', column_name=_('Default Location ID'), widget=widgets.ForeignKeyWidget(StockLocation)) + parent = Field( + attribute='parent', + column_name=_('Parent ID'), + widget=widgets.ForeignKeyWidget(models.PartCategory), + ) + parent_name = Field( + attribute='parent__name', column_name=_('Parent Name'), readonly=True + ) + default_location = Field( + attribute='default_location', + column_name=_('Default Location ID'), + widget=widgets.ForeignKeyWidget(StockLocation), + ) default_keywords = Field(attribute='default_keywords', column_name=_('Keywords')) pathstring = Field(attribute='pathstring', column_name=_('Category Path')) # Calculated fields - parts = Field(attribute='item_count', column_name=_('Parts'), widget=widgets.IntegerWidget(), readonly=True) + parts = Field( + attribute='item_count', + column_name=_('Parts'), + widget=widgets.IntegerWidget(), + readonly=True, + ) def after_import(self, dataset, result, using_transactions, dry_run, **kwargs): """Rebuild MPTT tree structure after importing PartCategory data""" @@ -215,7 +342,7 @@ class PartCategoryAdmin(ImportExportModelAdmin): search_fields = ('name', 'description') - autocomplete_fields = ('parent', 'default_location',) + autocomplete_fields = ('parent', 'default_location') class PartRelatedAdmin(admin.ModelAdmin): @@ -245,38 +372,63 @@ class BomItemResource(InvenTreeResource): class Meta: """Metaclass definition""" + model = models.BomItem skip_unchanged = True report_skipped = False clean_model_instances = True - exclude = [ - 'checksum', - 'id', - 'part', - 'sub_part', - 'validated', - ] + exclude = ['checksum', 'id', 'part', 'sub_part', 'validated'] level = Field(attribute='level', column_name=_('BOM Level'), readonly=True) - bom_id = Field(attribute='pk', column_name=_('BOM Item ID'), widget=widgets.IntegerWidget()) + bom_id = Field( + attribute='pk', column_name=_('BOM Item ID'), widget=widgets.IntegerWidget() + ) # ID of the parent part - parent_part_id = Field(attribute='part', column_name=_('Parent ID'), widget=widgets.ForeignKeyWidget(models.Part)) - parent_part_ipn = Field(attribute='part__IPN', column_name=_('Parent IPN'), readonly=True) - parent_part_name = Field(attribute='part__name', column_name=_('Parent Name'), readonly=True) - part_id = Field(attribute='sub_part', column_name=_('Part ID'), widget=widgets.ForeignKeyWidget(models.Part)) - part_ipn = Field(attribute='sub_part__IPN', column_name=_('Part IPN'), readonly=True) - part_name = Field(attribute='sub_part__name', column_name=_('Part Name'), readonly=True) - part_description = Field(attribute='sub_part__description', column_name=_('Description'), readonly=True) + parent_part_id = Field( + attribute='part', + column_name=_('Parent ID'), + widget=widgets.ForeignKeyWidget(models.Part), + ) + parent_part_ipn = Field( + attribute='part__IPN', column_name=_('Parent IPN'), readonly=True + ) + parent_part_name = Field( + attribute='part__name', column_name=_('Parent Name'), readonly=True + ) + part_id = Field( + attribute='sub_part', + column_name=_('Part ID'), + widget=widgets.ForeignKeyWidget(models.Part), + ) + part_ipn = Field( + attribute='sub_part__IPN', column_name=_('Part IPN'), readonly=True + ) + part_name = Field( + attribute='sub_part__name', column_name=_('Part Name'), readonly=True + ) + part_description = Field( + attribute='sub_part__description', column_name=_('Description'), readonly=True + ) quantity = Field(attribute='quantity', column_name=_('Quantity')) reference = Field(attribute='reference', column_name=_('Reference')) note = Field(attribute='note', column_name=_('Note')) - min_cost = Field(attribute='sub_part__pricing__overall_min', column_name=_('Minimum Price'), readonly=True) - max_cost = Field(attribute='sub_part__pricing__overall_max', column_name=_('Maximum Price'), readonly=True) + min_cost = Field( + attribute='sub_part__pricing__overall_min', + column_name=_('Minimum Price'), + readonly=True, + ) + max_cost = Field( + attribute='sub_part__pricing__overall_max', + column_name=_('Maximum Price'), + readonly=True, + ) - sub_assembly = Field(attribute='sub_part__assembly', column_name=_('Assembly'), readonly=True) + sub_assembly = Field( + attribute='sub_part__assembly', column_name=_('Assembly'), readonly=True + ) def dehydrate_min_cost(self, item): """Render minimum cost value for the BOM line item""" @@ -329,13 +481,12 @@ class BomItemResource(InvenTreeResource): 'part__name', 'sub_part__name', 'sub_part__description', - 'sub_part__assembly' + 'sub_part__assembly', ] idx = 0 while idx < len(fields): - if fields[idx].attribute in to_remove: del fields[idx] else: @@ -351,9 +502,14 @@ class BomItemAdmin(ImportExportModelAdmin): list_display = ('part', 'sub_part', 'quantity') - search_fields = ('part__name', 'part__description', 'sub_part__name', 'sub_part__description') + search_fields = ( + 'part__name', + 'part__description', + 'sub_part__name', + 'sub_part__description', + ) - autocomplete_fields = ('part', 'sub_part',) + autocomplete_fields = ('part', 'sub_part') class ParameterTemplateAdmin(ImportExportModelAdmin): @@ -369,6 +525,7 @@ class ParameterResource(InvenTreeResource): class Meta: """Metaclass definition""" + model = models.PartParameter skip_unchanged = True report_skipped = False @@ -378,7 +535,10 @@ class ParameterResource(InvenTreeResource): part_name = Field(attribute='part__name', readonly=True) - template = Field(attribute='template', widget=widgets.ForeignKeyWidget(models.PartParameterTemplate)) + template = Field( + attribute='template', + widget=widgets.ForeignKeyWidget(models.PartParameterTemplate), + ) template_name = Field(attribute='template__name', readonly=True) @@ -396,7 +556,7 @@ class ParameterAdmin(ImportExportModelAdmin): class PartCategoryParameterAdmin(admin.ModelAdmin): """Admin class for the PartCategoryParameterTemplate model""" - autocomplete_fields = ('category', 'parameter_template',) + autocomplete_fields = ('category', 'parameter_template') class PartSellPriceBreakAdmin(admin.ModelAdmin): @@ -404,9 +564,10 @@ class PartSellPriceBreakAdmin(admin.ModelAdmin): class Meta: """Metaclass definition""" + model = models.PartSellPriceBreak - list_display = ('part', 'quantity', 'price',) + list_display = ('part', 'quantity', 'price') class PartInternalPriceBreakAdmin(admin.ModelAdmin): @@ -414,9 +575,10 @@ class PartInternalPriceBreakAdmin(admin.ModelAdmin): class Meta: """Metaclass definition""" + model = models.PartInternalPriceBreak - list_display = ('part', 'quantity', 'price',) + list_display = ('part', 'quantity', 'price') autocomplete_fields = ('part',) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index f5664f4533..c7f744f46d 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -17,35 +17,68 @@ from rest_framework.response import Response import order.models import part.filters from build.models import Build, BuildItem -from InvenTree.api import (APIDownloadMixin, AttachmentMixin, - ListCreateDestroyAPIView, MetadataView) -from InvenTree.filters import (ORDER_FILTER, SEARCH_ORDER_FILTER, - SEARCH_ORDER_FILTER_ALIAS, InvenTreeDateFilter, - InvenTreeSearchFilter) -from InvenTree.helpers import (DownloadFile, increment_serial_number, isNull, - str2bool, str2int) -from InvenTree.mixins import (CreateAPI, CustomRetrieveUpdateDestroyAPI, - ListAPI, ListCreateAPI, RetrieveAPI, - RetrieveUpdateAPI, RetrieveUpdateDestroyAPI, - UpdateAPI) +from InvenTree.api import ( + APIDownloadMixin, + AttachmentMixin, + ListCreateDestroyAPIView, + MetadataView, +) +from InvenTree.filters import ( + ORDER_FILTER, + SEARCH_ORDER_FILTER, + SEARCH_ORDER_FILTER_ALIAS, + InvenTreeDateFilter, + InvenTreeSearchFilter, +) +from InvenTree.helpers import ( + DownloadFile, + increment_serial_number, + isNull, + str2bool, + str2int, +) +from InvenTree.mixins import ( + CreateAPI, + CustomRetrieveUpdateDestroyAPI, + ListAPI, + ListCreateAPI, + RetrieveAPI, + RetrieveUpdateAPI, + RetrieveUpdateDestroyAPI, + UpdateAPI, +) from InvenTree.permissions import RolePermission -from InvenTree.status_codes import (BuildStatusGroups, - PurchaseOrderStatusGroups, - SalesOrderStatusGroups) +from InvenTree.status_codes import ( + BuildStatusGroups, + PurchaseOrderStatusGroups, + SalesOrderStatusGroups, +) from part.admin import PartCategoryResource, PartResource from stock.models import StockLocation from . import serializers as part_serializers from . import views -from .models import (BomItem, BomItemSubstitute, Part, PartAttachment, - PartCategory, PartCategoryParameterTemplate, - PartInternalPriceBreak, PartParameter, - PartParameterTemplate, PartRelated, PartSellPriceBreak, - PartStocktake, PartStocktakeReport, PartTestTemplate) +from .models import ( + BomItem, + BomItemSubstitute, + Part, + PartAttachment, + PartCategory, + PartCategoryParameterTemplate, + PartInternalPriceBreak, + PartParameter, + PartParameterTemplate, + PartRelated, + PartSellPriceBreak, + PartStocktake, + PartStocktakeReport, + PartTestTemplate, +) class CategoryMixin: """Mixin class for PartCategory endpoints""" + serializer_class = part_serializers.CategorySerializer queryset = PartCategory.objects.all() @@ -60,7 +93,9 @@ class CategoryMixin: ctx = super().get_serializer_context() try: - ctx['starred_categories'] = [star.category for star in self.request.user.starred_categories.all()] + ctx['starred_categories'] = [ + star.category for star in self.request.user.starred_categories.all() + ] except AttributeError: # Error is thrown if the view does not have an associated request ctx['starred_categories'] = [] @@ -103,7 +138,6 @@ class CategoryList(CategoryMixin, APIDownloadMixin, ListCreateAPI): pass # Look for top-level categories elif isNull(cat_id): - if not cascade: queryset = queryset.filter(parent=None) @@ -147,7 +181,9 @@ class CategoryList(CategoryMixin, APIDownloadMixin, ListCreateAPI): if starred is not None: starred = str2bool(starred) - starred_categories = [star.category.pk for star in self.request.user.starred_categories.all()] + starred_categories = [ + star.category.pk for star in self.request.user.starred_categories.all() + ] if starred: queryset = queryset.filter(pk__in=starred_categories) @@ -158,32 +194,14 @@ class CategoryList(CategoryMixin, APIDownloadMixin, ListCreateAPI): filter_backends = SEARCH_ORDER_FILTER - filterset_fields = [ - 'name', - 'description', - 'structural' - ] + filterset_fields = ['name', 'description', 'structural'] - ordering_fields = [ - 'name', - 'pathstring', - 'level', - 'tree_id', - 'lft', - 'part_count', - ] + ordering_fields = ['name', 'pathstring', 'level', 'tree_id', 'lft', 'part_count'] # Use hierarchical ordering by default - ordering = [ - 'tree_id', - 'lft', - 'name' - ] + ordering = ['tree_id', 'lft', 'name'] - search_fields = [ - 'name', - 'description', - ] + search_fields = ['name', 'description'] class CategoryDetail(CategoryMixin, CustomRetrieveUpdateDestroyAPI): @@ -216,13 +234,22 @@ class CategoryDetail(CategoryMixin, CustomRetrieveUpdateDestroyAPI): def destroy(self, request, *args, **kwargs): """Delete a Part category instance via the API""" - delete_parts = 'delete_parts' in request.data and request.data['delete_parts'] == '1' - delete_child_categories = 'delete_child_categories' in request.data and request.data['delete_child_categories'] == '1' - return super().destroy(request, - *args, - **dict(kwargs, - delete_parts=delete_parts, - delete_child_categories=delete_child_categories)) + delete_parts = ( + 'delete_parts' in request.data and request.data['delete_parts'] == '1' + ) + delete_child_categories = ( + 'delete_child_categories' in request.data + and request.data['delete_child_categories'] == '1' + ) + return super().destroy( + request, + *args, + **dict( + kwargs, + delete_parts=delete_parts, + delete_child_categories=delete_child_categories, + ), + ) class CategoryTree(ListAPI): @@ -261,7 +288,6 @@ class CategoryParameterList(ListCreateAPI): if category is not None: try: - category = PartCategory.objects.get(pk=category) fetch_parent = str2bool(params.get('fetch_parent', True)) @@ -298,13 +324,9 @@ class PartSalePriceList(ListCreateAPI): queryset = PartSellPriceBreak.objects.all() serializer_class = part_serializers.PartSalePriceSerializer - filter_backends = [ - DjangoFilterBackend - ] + filter_backends = [DjangoFilterBackend] - filterset_fields = [ - 'part', - ] + filterset_fields = ['part'] class PartInternalPriceDetail(RetrieveUpdateDestroyAPI): @@ -321,13 +343,9 @@ class PartInternalPriceList(ListCreateAPI): serializer_class = part_serializers.PartInternalPriceSerializer permission_required = 'roles.sales_order.show' - filter_backends = [ - DjangoFilterBackend - ] + filter_backends = [DjangoFilterBackend] - filterset_fields = [ - 'part', - ] + filterset_fields = ['part'] class PartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView): @@ -336,9 +354,7 @@ class PartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView): queryset = PartAttachment.objects.all() serializer_class = part_serializers.PartAttachmentSerializer - filterset_fields = [ - 'part', - ] + filterset_fields = ['part'] class PartAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI): @@ -379,7 +395,9 @@ class PartTestTemplateList(ListCreateAPI): if part: try: part = Part.objects.get(pk=part) - queryset = queryset.filter(part__in=part.get_ancestors(include_self=True)) + queryset = queryset.filter( + part__in=part.get_ancestors(include_self=True) + ) except (ValueError, Part.DoesNotExist): pass @@ -417,15 +435,13 @@ class PartThumbs(ListAPI): queryset = self.filter_queryset(self.get_queryset()) # Return the most popular parts first - data = queryset.values( - 'image', - ).annotate(count=Count('image')).order_by('-count') + data = ( + queryset.values('image').annotate(count=Count('image')).order_by('-count') + ) return Response(data) - filter_backends = [ - InvenTreeSearchFilter, - ] + filter_backends = [InvenTreeSearchFilter] search_fields = [ 'name', @@ -443,9 +459,7 @@ class PartThumbsUpdate(RetrieveUpdateAPI): queryset = Part.objects.all() serializer_class = part_serializers.PartThumbSerializerUpdate - filter_backends = [ - DjangoFilterBackend - ] + filter_backends = [DjangoFilterBackend] class PartScheduling(RetrieveAPI): @@ -468,7 +482,9 @@ class PartScheduling(RetrieveAPI): schedule = [] - def add_schedule_entry(date, quantity, title, label, url, speculative_quantity=0): + def add_schedule_entry( + date, quantity, title, label, url, speculative_quantity=0 + ): """Check if a scheduled entry should be added: - date must be non-null @@ -487,12 +503,10 @@ class PartScheduling(RetrieveAPI): # Add purchase order (incoming stock) information po_lines = order.models.PurchaseOrderLineItem.objects.filter( - part__part=part, - order__status__in=PurchaseOrderStatusGroups.OPEN, + part__part=part, order__status__in=PurchaseOrderStatusGroups.OPEN ) for line in po_lines: - target_date = line.target_date or line.order.target_date line_quantity = max(line.quantity - line.received, 0) @@ -505,17 +519,15 @@ class PartScheduling(RetrieveAPI): quantity, _('Incoming Purchase Order'), str(line.order), - line.order.get_absolute_url() + line.order.get_absolute_url(), ) # Add sales order (outgoing stock) information so_lines = order.models.SalesOrderLineItem.objects.filter( - part=part, - order__status__in=SalesOrderStatusGroups.OPEN, + part=part, order__status__in=SalesOrderStatusGroups.OPEN ) for line in so_lines: - target_date = line.target_date or line.order.target_date quantity = max(line.quantity - line.shipped, 0) @@ -530,12 +542,10 @@ class PartScheduling(RetrieveAPI): # Add build orders (incoming stock) information build_orders = Build.objects.filter( - part=part, - status__in=BuildStatusGroups.ACTIVE_CODES + part=part, status__in=BuildStatusGroups.ACTIVE_CODES ) for build in build_orders: - quantity = max(build.quantity - build.completed, 0) add_schedule_entry( @@ -576,17 +586,14 @@ class PartScheduling(RetrieveAPI): # An "inherited" BOM item filters down to variant parts also children = bom_item.part.get_descendants(include_self=True) builds = Build.objects.filter( - status__in=BuildStatusGroups.ACTIVE_CODES, - part__in=children, + status__in=BuildStatusGroups.ACTIVE_CODES, part__in=children ) else: builds = Build.objects.filter( - status__in=BuildStatusGroups.ACTIVE_CODES, - part=bom_item.part, + status__in=BuildStatusGroups.ACTIVE_CODES, part=bom_item.part ) for build in builds: - # Ensure we don't double-count any builds if build in seen_builds: continue @@ -602,8 +609,7 @@ class PartScheduling(RetrieveAPI): # Grab all allocations against the specified BomItem allocations = BuildItem.objects.filter( - build_line__bom_item=bom_item, - build_line__build=build, + build_line__bom_item=bom_item, build_line__build=build ) # Total allocated for *this* part @@ -622,7 +628,9 @@ class PartScheduling(RetrieveAPI): # Consider the case where the build order is *not* fully allocated if required_quantity > total_allocated_quantity: - speculative_quantity = -1 * (required_quantity - total_allocated_quantity) + speculative_quantity = -1 * ( + required_quantity - total_allocated_quantity + ) add_schedule_entry( build.target_date, @@ -630,7 +638,7 @@ class PartScheduling(RetrieveAPI): _('Stock required for Build Order'), str(build), build.get_absolute_url(), - speculative_quantity=speculative_quantity + speculative_quantity=speculative_quantity, ) def compare(entry_1, entry_2): @@ -679,11 +687,19 @@ class PartRequirements(RetrieveAPI): "required_build_order_quantity": part.required_build_order_quantity(), "allocated_build_order_quantity": part.build_order_allocation_count(), "required_sales_order_quantity": part.required_sales_order_quantity(), - "allocated_sales_order_quantity": part.sales_order_allocation_count(pending=True), + "allocated_sales_order_quantity": part.sales_order_allocation_count( + pending=True + ), } - data["allocated"] = data["allocated_build_order_quantity"] + data["allocated_sales_order_quantity"] - data["required"] = data["required_build_order_quantity"] + data["required_sales_order_quantity"] + data["allocated"] = ( + data["allocated_build_order_quantity"] + + data["allocated_sales_order_quantity"] + ) + data["required"] = ( + data["required_build_order_quantity"] + + data["required_sales_order_quantity"] + ) return Response(data) @@ -719,9 +735,7 @@ class PartSerialNumberDetail(RetrieveAPI): # Calculate the "latest" serial number latest = part.get_latest_serial_number() - data = { - 'latest': latest, - } + data = {'latest': latest} if latest is not None: next_serial = increment_serial_number(latest) @@ -758,16 +772,11 @@ class PartValidateBOM(RetrieveUpdateAPI): class Meta: """Metaclass defines serializer fields""" - model = Part - fields = [ - 'checksum', - 'valid', - ] - checksum = serializers.CharField( - read_only=True, - source='bom_checksum', - ) + model = Part + fields = ['checksum', 'valid'] + + checksum = serializers.CharField(read_only=True, source='bom_checksum') valid = serializers.BooleanField( write_only=True, @@ -799,9 +808,7 @@ class PartValidateBOM(RetrieveUpdateAPI): part.validate_bom(request.user) - return Response({ - 'checksum': part.bom_checksum, - }) + return Response({'checksum': part.bom_checksum}) class PartFilter(rest_filters.FilterSet): @@ -812,6 +819,7 @@ class PartFilter(rest_filters.FilterSet): class Meta: """Metaclass options for this filter set""" + model = Part fields = [] @@ -834,17 +842,23 @@ class PartFilter(rest_filters.FilterSet): return queryset.filter(IPN='') # Regex filter for name - name_regex = rest_filters.CharFilter(label='Filter by name (regex)', field_name='name', lookup_expr='iregex') + name_regex = rest_filters.CharFilter( + label='Filter by name (regex)', field_name='name', lookup_expr='iregex' + ) # Exact match for IPN IPN = rest_filters.CharFilter( label='Filter by exact IPN (internal part number)', field_name='IPN', - lookup_expr="iexact" + lookup_expr="iexact", ) # Regex match for IPN - IPN_regex = rest_filters.CharFilter(label='Filter by regex on IPN (internal part number)', field_name='IPN', lookup_expr='iregex') + IPN_regex = rest_filters.CharFilter( + label='Filter by regex on IPN (internal part number)', + field_name='IPN', + lookup_expr='iregex', + ) # low_stock filter low_stock = rest_filters.BooleanFilter(label='Low stock', method='filter_low_stock') @@ -854,7 +868,9 @@ class PartFilter(rest_filters.FilterSet): if str2bool(value): # Ignore any parts which do not have a specified 'minimum_stock' level # Filter items which have an 'in_stock' level lower than 'minimum_stock' - return queryset.exclude(minimum_stock=0).filter(Q(total_in_stock__lt=F('minimum_stock'))) + return queryset.exclude(minimum_stock=0).filter( + Q(total_in_stock__lt=F('minimum_stock')) + ) # Filter items which have an 'in_stock' level higher than 'minimum_stock' return queryset.filter(Q(total_in_stock__gte=F('minimum_stock'))) @@ -868,7 +884,9 @@ class PartFilter(rest_filters.FilterSet): return queryset.filter(Q(in_stock__lte=0)) # unallocated_stock filter - unallocated_stock = rest_filters.BooleanFilter(label='Unallocated stock', method='filter_unallocated_stock') + unallocated_stock = rest_filters.BooleanFilter( + label='Unallocated stock', method='filter_unallocated_stock' + ) def filter_unallocated_stock(self, queryset, name, value): """Filter by whether the Part has unallocated stock""" @@ -876,7 +894,11 @@ class PartFilter(rest_filters.FilterSet): return queryset.filter(Q(unallocated_stock__gt=0)) return queryset.filter(Q(unallocated_stock__lte=0)) - convert_from = rest_filters.ModelChoiceFilter(label="Can convert from", queryset=Part.objects.all(), method='filter_convert_from') + convert_from = rest_filters.ModelChoiceFilter( + label="Can convert from", + queryset=Part.objects.all(), + method='filter_convert_from', + ) def filter_convert_from(self, queryset, name, part): """Limit the queryset to valid conversion options for the specified part""" @@ -886,7 +908,11 @@ class PartFilter(rest_filters.FilterSet): return queryset - exclude_tree = rest_filters.ModelChoiceFilter(label="Exclude Part tree", queryset=Part.objects.all(), method='filter_exclude_tree') + exclude_tree = rest_filters.ModelChoiceFilter( + label="Exclude Part tree", + queryset=Part.objects.all(), + method='filter_exclude_tree', + ) def filter_exclude_tree(self, queryset, name, part): """Exclude all parts and variants 'down' from the specified part from the queryset""" @@ -894,27 +920,35 @@ class PartFilter(rest_filters.FilterSet): return queryset.exclude(id__in=children) - ancestor = rest_filters.ModelChoiceFilter(label='Ancestor', queryset=Part.objects.all(), method='filter_ancestor') + ancestor = rest_filters.ModelChoiceFilter( + label='Ancestor', queryset=Part.objects.all(), method='filter_ancestor' + ) def filter_ancestor(self, queryset, name, part): """Limit queryset to descendants of the specified ancestor part""" descendants = part.get_descendants(include_self=False) return queryset.filter(id__in=descendants) - variant_of = rest_filters.ModelChoiceFilter(label='Variant Of', queryset=Part.objects.all(), method='filter_variant_of') + variant_of = rest_filters.ModelChoiceFilter( + label='Variant Of', queryset=Part.objects.all(), method='filter_variant_of' + ) def filter_variant_of(self, queryset, name, part): """Limit queryset to direct children (variants) of the specified part""" return queryset.filter(id__in=part.get_children()) - in_bom_for = rest_filters.ModelChoiceFilter(label='In BOM Of', queryset=Part.objects.all(), method='filter_in_bom') + in_bom_for = rest_filters.ModelChoiceFilter( + label='In BOM Of', queryset=Part.objects.all(), method='filter_in_bom' + ) def filter_in_bom(self, queryset, name, part): """Limit queryset to parts in the BOM for the specified part""" bom_parts = part.get_parts_in_bom() return queryset.filter(id__in=[p.pk for p in bom_parts]) - has_pricing = rest_filters.BooleanFilter(label="Has Pricing", method="filter_has_pricing") + has_pricing = rest_filters.BooleanFilter( + label="Has Pricing", method="filter_has_pricing" + ) def filter_has_pricing(self, queryset, name, value): """Filter the queryset based on whether pricing information is available for the sub_part""" @@ -926,7 +960,9 @@ class PartFilter(rest_filters.FilterSet): return queryset.filter(q_a | q_b).distinct() - stocktake = rest_filters.BooleanFilter(label="Has stocktake", method='filter_has_stocktake') + stocktake = rest_filters.BooleanFilter( + label="Has stocktake", method='filter_has_stocktake' + ) def filter_has_stocktake(self, queryset, name, value): """Filter the queryset based on whether stocktake data is available""" @@ -934,17 +970,25 @@ class PartFilter(rest_filters.FilterSet): return queryset.exclude(last_stocktake=None) return queryset.filter(last_stocktake=None) - stock_to_build = rest_filters.BooleanFilter(label='Required for Build Order', method='filter_stock_to_build') + stock_to_build = rest_filters.BooleanFilter( + label='Required for Build Order', method='filter_stock_to_build' + ) def filter_stock_to_build(self, queryset, name, value): """Filter the queryset based on whether part stock is required for a pending BuildOrder""" if str2bool(value): # Return parts which are required for a build order, but have not yet been allocated - return queryset.filter(required_for_build_orders__gt=F('allocated_to_build_orders')) + return queryset.filter( + required_for_build_orders__gt=F('allocated_to_build_orders') + ) # Return parts which are not required for a build order, or have already been allocated - return queryset.filter(required_for_build_orders__lte=F('allocated_to_build_orders')) + return queryset.filter( + required_for_build_orders__lte=F('allocated_to_build_orders') + ) - depleted_stock = rest_filters.BooleanFilter(label='Depleted Stock', method='filter_depleted_stock') + depleted_stock = rest_filters.BooleanFilter( + label='Depleted Stock', method='filter_depleted_stock' + ) def filter_depleted_stock(self, queryset, name, value): """Filter the queryset based on whether the part is fully depleted of stock""" @@ -952,7 +996,9 @@ class PartFilter(rest_filters.FilterSet): return queryset.filter(Q(in_stock=0) & ~Q(stock_item_count=0)) return queryset.exclude(Q(in_stock=0) & ~Q(stock_item_count=0)) - default_location = rest_filters.ModelChoiceFilter(label="Default Location", queryset=StockLocation.objects.all()) + default_location = rest_filters.ModelChoiceFilter( + label="Default Location", queryset=StockLocation.objects.all() + ) is_template = rest_filters.BooleanFilter() @@ -975,12 +1021,17 @@ class PartFilter(rest_filters.FilterSet): tags_slug = rest_filters.CharFilter(field_name='tags__slug', lookup_expr='iexact') # Created date filters - created_before = InvenTreeDateFilter(label='Updated before', field_name='creation_date', lookup_expr='lte') - created_after = InvenTreeDateFilter(label='Updated after', field_name='creation_date', lookup_expr='gte') + created_before = InvenTreeDateFilter( + label='Updated before', field_name='creation_date', lookup_expr='lte' + ) + created_after = InvenTreeDateFilter( + label='Updated after', field_name='creation_date', lookup_expr='gte' + ) class PartMixin: """Mixin class for Part API endpoints""" + serializer_class = part_serializers.PartSerializer queryset = Part.objects.all() @@ -1007,7 +1058,9 @@ class PartMixin: # Pass a list of "starred" parts to the current user to the serializer # We do this to reduce the number of database queries required! if self.starred_parts is None and self.request is not None: - self.starred_parts = [star.part for star in self.request.user.starred_parts.all()] + self.starred_parts = [ + star.part for star in self.request.user.starred_parts.all() + ] kwargs['starred_parts'] = self.starred_parts @@ -1087,7 +1140,6 @@ class PartList(PartMixin, APIDownloadMixin, ListCreateAPI): exclude_id += params.getlist(key, []) if exclude_id: - id_values = [] for val in exclude_id: @@ -1107,7 +1159,6 @@ class PartList(PartMixin, APIDownloadMixin, ListCreateAPI): # TODO: (It needs to be profiled!) # TODO: It might be worth caching the bom_valid status to a database column if bom_valid is not None: - bom_valid = str2bool(bom_valid) # Limit queryset to active assemblies @@ -1138,7 +1189,6 @@ class PartList(PartMixin, APIDownloadMixin, ListCreateAPI): relation_filter = Q(part_1=related_part) | Q(part_2=related_part) for relation in PartRelated.objects.filter(relation_filter).distinct(): - if relation.part_1.pk != pk: part_ids.add(relation.part_1.pk) @@ -1160,7 +1210,9 @@ class PartList(PartMixin, APIDownloadMixin, ListCreateAPI): if starred is not None: starred = str2bool(starred) - starred_parts = [star.part.pk for star in self.request.user.starred_parts.all()] + starred_parts = [ + star.part.pk for star in self.request.user.starred_parts.all() + ] if starred: queryset = queryset.filter(pk__in=starred_parts) @@ -1187,7 +1239,9 @@ class PartList(PartMixin, APIDownloadMixin, ListCreateAPI): # If '?cascade=true' then include parts which exist in sub-categories if cascade: - queryset = queryset.filter(category__in=category.getUniqueChildren()) + queryset = queryset.filter( + category__in=category.getUniqueChildren() + ) # Just return parts directly in the requested category else: queryset = queryset.filter(category=cat_id) @@ -1219,7 +1273,9 @@ class PartList(PartMixin, APIDownloadMixin, ListCreateAPI): if result: template_id = result.group(1) ascending = not ordering.startswith('-') - queryset = part.filters.order_by_parameter(queryset, template_id, ascending) + queryset = part.filters.order_by_parameter( + queryset, template_id, ascending + ) return queryset @@ -1339,14 +1395,10 @@ class PartParameterTemplateFilter(rest_filters.FilterSet): model = PartParameterTemplate # Simple filter fields - fields = [ - 'units', - 'checkbox', - ] + fields = ['units', 'checkbox'] has_choices = rest_filters.BooleanFilter( - method='filter_has_choices', - label='Has Choice', + method='filter_has_choices', label='Has Choice' ) def filter_has_choices(self, queryset, name, value): @@ -1356,10 +1408,7 @@ class PartParameterTemplateFilter(rest_filters.FilterSet): return queryset.filter(Q(choices=None) | Q(choices='')).distinct() - has_units = rest_filters.BooleanFilter( - method='filter_has_units', - label='Has Units', - ) + has_units = rest_filters.BooleanFilter(method='filter_has_units', label='Has Units') def filter_has_units(self, queryset, name, value): """Filter queryset to include only PartParameterTemplates with units.""" @@ -1382,20 +1431,11 @@ class PartParameterTemplateList(ListCreateAPI): filter_backends = SEARCH_ORDER_FILTER - filterset_fields = [ - 'name', - ] + filterset_fields = ['name'] - search_fields = [ - 'name', - 'description', - ] + search_fields = ['name', 'description'] - ordering_fields = [ - 'name', - 'units', - 'checkbox', - ] + ordering_fields = ['name', 'units', 'checkbox'] def filter_queryset(self, queryset): """Custom filtering for the PartParameterTemplate API.""" @@ -1407,7 +1447,6 @@ class PartParameterTemplateList(ListCreateAPI): part = params.get('part', None) if part is not None: - try: part = Part.objects.get(pk=part) parameters = PartParameter.objects.filter(part=part) @@ -1420,7 +1459,6 @@ class PartParameterTemplateList(ListCreateAPI): category = params.get('category', None) if category is not None: - try: category = PartCategory.objects.get(pk=category) cats = category.get_descendants(include_self=True) @@ -1442,6 +1480,7 @@ class PartParameterTemplateDetail(RetrieveUpdateDestroyAPI): class PartParameterAPIMixin: """Mixin class for PartParameter API endpoints.""" + queryset = PartParameter.objects.all() serializer_class = part_serializers.PartParameterSerializer @@ -1460,7 +1499,9 @@ class PartParameterAPIMixin: """ try: kwargs['part_detail'] = str2bool(self.request.GET.get('part_detail', False)) - kwargs['template_detail'] = str2bool(self.request.GET.get('template_detail', True)) + kwargs['template_detail'] = str2bool( + self.request.GET.get('template_detail', True) + ) except AttributeError: pass @@ -1472,12 +1513,13 @@ class PartParameterFilter(rest_filters.FilterSet): class Meta: """Metaclass options for the filterset""" - model = PartParameter - fields = [ - 'template' - ] - part = rest_filters.ModelChoiceFilter(queryset=Part.objects.all(), method='filter_part') + model = PartParameter + fields = ['template'] + + part = rest_filters.ModelChoiceFilter( + queryset=Part.objects.all(), method='filter_part' + ) def filter_part(self, queryset, name, part): """Filter against the provided part. @@ -1506,12 +1548,7 @@ class PartParameterList(PartParameterAPIMixin, ListCreateAPI): filter_backends = SEARCH_ORDER_FILTER_ALIAS - ordering_fields = [ - 'name', - 'data', - 'part', - 'template', - ] + ordering_fields = ['name', 'data', 'part', 'template'] ordering_field_aliases = { 'name': 'template__name', @@ -1528,6 +1565,7 @@ class PartParameterList(PartParameterAPIMixin, ListCreateAPI): class PartParameterDetail(PartParameterAPIMixin, RetrieveUpdateDestroyAPI): """API endpoint for detail view of a single PartParameter object.""" + pass @@ -1538,10 +1576,7 @@ class PartStocktakeFilter(rest_filters.FilterSet): """Metaclass options""" model = PartStocktake - fields = [ - 'part', - 'user', - ] + fields = ['part', 'user'] class PartStocktakeList(ListCreateAPI): @@ -1560,14 +1595,7 @@ class PartStocktakeList(ListCreateAPI): filter_backends = ORDER_FILTER - ordering_fields = [ - 'part', - 'item_count', - 'quantity', - 'date', - 'user', - 'pk', - ] + ordering_fields = ['part', 'item_count', 'quantity', 'date', 'user', 'pk'] # Reverse date ordering by default ordering = '-pk' @@ -1591,10 +1619,7 @@ class PartStocktakeReportList(ListAPI): filter_backends = ORDER_FILTER - ordering_fields = [ - 'date', - 'pk', - ] + ordering_fields = ['date', 'pk'] # Newest first, by default ordering = '-pk' @@ -1605,10 +1630,7 @@ class PartStocktakeReportGenerate(CreateAPI): serializer_class = part_serializers.PartStocktakeReportGenerateSerializer - permission_classes = [ - permissions.IsAuthenticated, - RolePermission, - ] + permission_classes = [permissions.IsAuthenticated, RolePermission] role_required = 'stocktake' @@ -1627,23 +1649,27 @@ class BomFilter(rest_filters.FilterSet): """Metaclass options""" model = BomItem - fields = [ - 'optional', - 'consumable', - 'inherited', - 'allow_variants', - 'validated', - ] + fields = ['optional', 'consumable', 'inherited', 'allow_variants', 'validated'] # Filters for linked 'part' - part_active = rest_filters.BooleanFilter(label='Master part is active', field_name='part__active') - part_trackable = rest_filters.BooleanFilter(label='Master part is trackable', field_name='part__trackable') + part_active = rest_filters.BooleanFilter( + label='Master part is active', field_name='part__active' + ) + part_trackable = rest_filters.BooleanFilter( + label='Master part is trackable', field_name='part__trackable' + ) # Filters for linked 'sub_part' - sub_part_trackable = rest_filters.BooleanFilter(label='Sub part is trackable', field_name='sub_part__trackable') - sub_part_assembly = rest_filters.BooleanFilter(label='Sub part is an assembly', field_name='sub_part__assembly') + sub_part_trackable = rest_filters.BooleanFilter( + label='Sub part is trackable', field_name='sub_part__trackable' + ) + sub_part_assembly = rest_filters.BooleanFilter( + label='Sub part is an assembly', field_name='sub_part__assembly' + ) - available_stock = rest_filters.BooleanFilter(label="Has available stock", method="filter_available_stock") + available_stock = rest_filters.BooleanFilter( + label="Has available stock", method="filter_available_stock" + ) def filter_available_stock(self, queryset, name, value): """Filter the queryset based on whether each line item has any available stock""" @@ -1659,12 +1685,17 @@ class BomFilter(rest_filters.FilterSet): return queryset.filter(on_order__gt=0) return queryset.filter(on_order=0) - has_pricing = rest_filters.BooleanFilter(label="Has Pricing", method="filter_has_pricing") + has_pricing = rest_filters.BooleanFilter( + label="Has Pricing", method="filter_has_pricing" + ) def filter_has_pricing(self, queryset, name, value): """Filter the queryset based on whether pricing information is available for the sub_part""" q_a = Q(sub_part__pricing_data=None) - q_b = Q(sub_part__pricing_data__overall_min=None, sub_part__pricing_data__overall_max=None) + q_b = Q( + sub_part__pricing_data__overall_min=None, + sub_part__pricing_data__overall_max=None, + ) if str2bool(value): return queryset.exclude(q_a | q_b) @@ -1692,7 +1723,9 @@ class BomMixin: pass try: - kwargs['sub_part_detail'] = str2bool(self.request.GET.get('sub_part_detail', None)) + kwargs['sub_part_detail'] = str2bool( + self.request.GET.get('sub_part_detail', None) + ) except AttributeError: pass @@ -1789,7 +1822,6 @@ class BomList(BomMixin, ListCreateDestroyAPIView): uses = params.get('uses', None) if uses is not None: - try: # Extract the part we are interested in uses_part = Part.objects.get(pk=uses) @@ -1823,13 +1855,12 @@ class BomList(BomMixin, ListCreateDestroyAPIView): 'consumable', ] - ordering_field_aliases = { - 'sub_part': 'sub_part__name', - } + ordering_field_aliases = {'sub_part': 'sub_part__name'} class BomDetail(BomMixin, RetrieveUpdateDestroyAPI): """API endpoint for detail view of a single BomItem object.""" + pass @@ -1876,6 +1907,7 @@ class BomItemValidate(UpdateAPI): class BomItemValidationSerializer(serializers.Serializer): """Simple serializer for passing a single boolean field""" + valid = serializers.BooleanField(default=False) queryset = BomItem.objects.all() @@ -1908,10 +1940,7 @@ class BomItemSubstituteList(ListCreateAPI): filter_backends = SEARCH_ORDER_FILTER - filterset_fields = [ - 'part', - 'bom_item', - ] + filterset_fields = ['part', 'bom_item'] class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): @@ -1922,167 +1951,351 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): part_api_urls = [ - # Base URL for PartCategory API endpoints - re_path(r'^category/', include([ - re_path(r'^tree/', CategoryTree.as_view(), name='api-part-category-tree'), - - re_path(r'^parameters/', include([ - re_path(r'^(?P\d+)/', include([ - re_path(r'^metadata/', MetadataView.as_view(), {'model': PartCategoryParameterTemplate}, name='api-part-category-parameter-metadata'), - re_path(r'^.*$', CategoryParameterDetail.as_view(), name='api-part-category-parameter-detail'), - ])), - re_path(r'^.*$', CategoryParameterList.as_view(), name='api-part-category-parameter-list'), - ])), - - # Category detail endpoints - path(r'/', include([ - - re_path(r'^metadata/', MetadataView.as_view(), {'model': PartCategory}, name='api-part-category-metadata'), - - # PartCategory detail endpoint - re_path(r'^.*$', CategoryDetail.as_view(), name='api-part-category-detail'), - ])), - - path('', CategoryList.as_view(), name='api-part-category-list'), - ])), - + re_path( + r'^category/', + include([ + re_path(r'^tree/', CategoryTree.as_view(), name='api-part-category-tree'), + re_path( + r'^parameters/', + include([ + re_path( + r'^(?P\d+)/', + include([ + re_path( + r'^metadata/', + MetadataView.as_view(), + {'model': PartCategoryParameterTemplate}, + name='api-part-category-parameter-metadata', + ), + re_path( + r'^.*$', + CategoryParameterDetail.as_view(), + name='api-part-category-parameter-detail', + ), + ]), + ), + re_path( + r'^.*$', + CategoryParameterList.as_view(), + name='api-part-category-parameter-list', + ), + ]), + ), + # Category detail endpoints + path( + r'/', + include([ + re_path( + r'^metadata/', + MetadataView.as_view(), + {'model': PartCategory}, + name='api-part-category-metadata', + ), + # PartCategory detail endpoint + re_path( + r'^.*$', + CategoryDetail.as_view(), + name='api-part-category-detail', + ), + ]), + ), + path('', CategoryList.as_view(), name='api-part-category-list'), + ]), + ), # Base URL for PartTestTemplate API endpoints - re_path(r'^test-template/', include([ - path(r'/', include([ - re_path(r'^metadata/', MetadataView.as_view(), {'model': PartTestTemplate}, name='api-part-test-template-metadata'), - re_path(r'^.*$', PartTestTemplateDetail.as_view(), name='api-part-test-template-detail'), - ])), - path('', PartTestTemplateList.as_view(), name='api-part-test-template-list'), - ])), - + re_path( + r'^test-template/', + include([ + path( + r'/', + include([ + re_path( + r'^metadata/', + MetadataView.as_view(), + {'model': PartTestTemplate}, + name='api-part-test-template-metadata', + ), + re_path( + r'^.*$', + PartTestTemplateDetail.as_view(), + name='api-part-test-template-detail', + ), + ]), + ), + path( + '', PartTestTemplateList.as_view(), name='api-part-test-template-list' + ), + ]), + ), # Base URL for PartAttachment API endpoints - re_path(r'^attachment/', include([ - path(r'/', PartAttachmentDetail.as_view(), name='api-part-attachment-detail'), - path('', PartAttachmentList.as_view(), name='api-part-attachment-list'), - ])), - + re_path( + r'^attachment/', + include([ + path( + r'/', + PartAttachmentDetail.as_view(), + name='api-part-attachment-detail', + ), + path('', PartAttachmentList.as_view(), name='api-part-attachment-list'), + ]), + ), # Base URL for part sale pricing - re_path(r'^sale-price/', include([ - path(r'/', PartSalePriceDetail.as_view(), name='api-part-sale-price-detail'), - re_path(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'), - ])), - + re_path( + r'^sale-price/', + include([ + path( + r'/', + PartSalePriceDetail.as_view(), + name='api-part-sale-price-detail', + ), + re_path( + r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list' + ), + ]), + ), # Base URL for part internal pricing - re_path(r'^internal-price/', include([ - path(r'/', PartInternalPriceDetail.as_view(), name='api-part-internal-price-detail'), - re_path(r'^.*$', PartInternalPriceList.as_view(), name='api-part-internal-price-list'), - ])), - + re_path( + r'^internal-price/', + include([ + path( + r'/', + PartInternalPriceDetail.as_view(), + name='api-part-internal-price-detail', + ), + re_path( + r'^.*$', + PartInternalPriceList.as_view(), + name='api-part-internal-price-list', + ), + ]), + ), # Base URL for PartRelated API endpoints - re_path(r'^related/', include([ - path(r'/', include([ - re_path(r'^metadata/', MetadataView.as_view(), {'model': PartRelated}, name='api-part-related-metadata'), - re_path(r'^.*$', PartRelatedDetail.as_view(), name='api-part-related-detail'), - ])), - re_path(r'^.*$', PartRelatedList.as_view(), name='api-part-related-list'), - ])), - + re_path( + r'^related/', + include([ + path( + r'/', + include([ + re_path( + r'^metadata/', + MetadataView.as_view(), + {'model': PartRelated}, + name='api-part-related-metadata', + ), + re_path( + r'^.*$', + PartRelatedDetail.as_view(), + name='api-part-related-detail', + ), + ]), + ), + re_path(r'^.*$', PartRelatedList.as_view(), name='api-part-related-list'), + ]), + ), # Base URL for PartParameter API endpoints - re_path(r'^parameter/', include([ - path('template/', include([ - re_path(r'^(?P\d+)/', include([ - re_path(r'^metadata/?', MetadataView.as_view(), {'model': PartParameterTemplate}, name='api-part-parameter-template-metadata'), - re_path(r'^.*$', PartParameterTemplateDetail.as_view(), name='api-part-parameter-template-detail'), - ])), - re_path(r'^.*$', PartParameterTemplateList.as_view(), name='api-part-parameter-template-list'), - ])), - - path(r'/', include([ - re_path(r'^metadata/?', MetadataView.as_view(), {'model': PartParameter}, name='api-part-parameter-metadata'), - re_path(r'^.*$', PartParameterDetail.as_view(), name='api-part-parameter-detail'), - ])), - re_path(r'^.*$', PartParameterList.as_view(), name='api-part-parameter-list'), - ])), - + re_path( + r'^parameter/', + include([ + path( + 'template/', + include([ + re_path( + r'^(?P\d+)/', + include([ + re_path( + r'^metadata/?', + MetadataView.as_view(), + {'model': PartParameterTemplate}, + name='api-part-parameter-template-metadata', + ), + re_path( + r'^.*$', + PartParameterTemplateDetail.as_view(), + name='api-part-parameter-template-detail', + ), + ]), + ), + re_path( + r'^.*$', + PartParameterTemplateList.as_view(), + name='api-part-parameter-template-list', + ), + ]), + ), + path( + r'/', + include([ + re_path( + r'^metadata/?', + MetadataView.as_view(), + {'model': PartParameter}, + name='api-part-parameter-metadata', + ), + re_path( + r'^.*$', + PartParameterDetail.as_view(), + name='api-part-parameter-detail', + ), + ]), + ), + re_path( + r'^.*$', PartParameterList.as_view(), name='api-part-parameter-list' + ), + ]), + ), # Part stocktake data - re_path(r'^stocktake/', include([ - - path(r'report/', include([ - path('generate/', PartStocktakeReportGenerate.as_view(), name='api-part-stocktake-report-generate'), - re_path(r'^.*$', PartStocktakeReportList.as_view(), name='api-part-stocktake-report-list'), - ])), - - path(r'/', PartStocktakeDetail.as_view(), name='api-part-stocktake-detail'), - re_path(r'^.*$', PartStocktakeList.as_view(), name='api-part-stocktake-list'), - ])), - - re_path(r'^thumbs/', include([ - path('', PartThumbs.as_view(), name='api-part-thumbs'), - re_path(r'^(?P\d+)/?', PartThumbsUpdate.as_view(), name='api-part-thumbs-update'), - ])), - + re_path( + r'^stocktake/', + include([ + path( + r'report/', + include([ + path( + 'generate/', + PartStocktakeReportGenerate.as_view(), + name='api-part-stocktake-report-generate', + ), + re_path( + r'^.*$', + PartStocktakeReportList.as_view(), + name='api-part-stocktake-report-list', + ), + ]), + ), + path( + r'/', + PartStocktakeDetail.as_view(), + name='api-part-stocktake-detail', + ), + re_path( + r'^.*$', PartStocktakeList.as_view(), name='api-part-stocktake-list' + ), + ]), + ), + re_path( + r'^thumbs/', + include([ + path('', PartThumbs.as_view(), name='api-part-thumbs'), + re_path( + r'^(?P\d+)/?', + PartThumbsUpdate.as_view(), + name='api-part-thumbs-update', + ), + ]), + ), # BOM template - re_path(r'^bom_template/?', views.BomUploadTemplate.as_view(), name='api-bom-upload-template'), - - path(r'/', include([ - - # Endpoint for extra serial number information - re_path(r'^serial-numbers/', PartSerialNumberDetail.as_view(), name='api-part-serial-number-detail'), - - # Endpoint for future scheduling information - re_path(r'^scheduling/', PartScheduling.as_view(), name='api-part-scheduling'), - - re_path(r'^requirements/', PartRequirements.as_view(), name='api-part-requirements'), - - # Endpoint for duplicating a BOM for the specific Part - re_path(r'^bom-copy/', PartCopyBOM.as_view(), name='api-part-bom-copy'), - - # Endpoint for validating a BOM for the specific Part - re_path(r'^bom-validate/', PartValidateBOM.as_view(), name='api-part-bom-validate'), - - # Part metadata - re_path(r'^metadata/', MetadataView.as_view(), {'model': Part}, name='api-part-metadata'), - - # Part pricing - re_path(r'^pricing/', PartPricingDetail.as_view(), name='api-part-pricing'), - - # BOM download - re_path(r'^bom-download/?', views.BomDownload.as_view(), name='api-bom-download'), - - # Old pricing endpoint - re_path(r'^pricing2/', views.PartPricing.as_view(), name='part-pricing'), - - # Part detail endpoint - re_path(r'^.*$', PartDetail.as_view(), name='api-part-detail'), - ])), - - re_path(r'^change_category/', PartChangeCategory.as_view(), name='api-part-change-category'), - + re_path( + r'^bom_template/?', + views.BomUploadTemplate.as_view(), + name='api-bom-upload-template', + ), + path( + r'/', + include([ + # Endpoint for extra serial number information + re_path( + r'^serial-numbers/', + PartSerialNumberDetail.as_view(), + name='api-part-serial-number-detail', + ), + # Endpoint for future scheduling information + re_path( + r'^scheduling/', PartScheduling.as_view(), name='api-part-scheduling' + ), + re_path( + r'^requirements/', + PartRequirements.as_view(), + name='api-part-requirements', + ), + # Endpoint for duplicating a BOM for the specific Part + re_path(r'^bom-copy/', PartCopyBOM.as_view(), name='api-part-bom-copy'), + # Endpoint for validating a BOM for the specific Part + re_path( + r'^bom-validate/', + PartValidateBOM.as_view(), + name='api-part-bom-validate', + ), + # Part metadata + re_path( + r'^metadata/', + MetadataView.as_view(), + {'model': Part}, + name='api-part-metadata', + ), + # Part pricing + re_path(r'^pricing/', PartPricingDetail.as_view(), name='api-part-pricing'), + # BOM download + re_path( + r'^bom-download/?', views.BomDownload.as_view(), name='api-bom-download' + ), + # Old pricing endpoint + re_path(r'^pricing2/', views.PartPricing.as_view(), name='part-pricing'), + # Part detail endpoint + re_path(r'^.*$', PartDetail.as_view(), name='api-part-detail'), + ]), + ), + re_path( + r'^change_category/', + PartChangeCategory.as_view(), + name='api-part-change-category', + ), re_path(r'^.*$', PartList.as_view(), name='api-part-list'), ] bom_api_urls = [ - - re_path(r'^substitute/', include([ - - # Detail view - path(r'/', include([ - re_path(r'^metadata/?', MetadataView.as_view(), {'model': BomItemSubstitute}, name='api-bom-substitute-metadata'), - re_path(r'^.*$', BomItemSubstituteDetail.as_view(), name='api-bom-substitute-detail'), - ])), - - # Catch all - re_path(r'^.*$', BomItemSubstituteList.as_view(), name='api-bom-substitute-list'), - ])), - + re_path( + r'^substitute/', + include([ + # Detail view + path( + r'/', + include([ + re_path( + r'^metadata/?', + MetadataView.as_view(), + {'model': BomItemSubstitute}, + name='api-bom-substitute-metadata', + ), + re_path( + r'^.*$', + BomItemSubstituteDetail.as_view(), + name='api-bom-substitute-detail', + ), + ]), + ), + # Catch all + re_path( + r'^.*$', BomItemSubstituteList.as_view(), name='api-bom-substitute-list' + ), + ]), + ), # BOM Item Detail - path(r'/', include([ - re_path(r'^validate/?', BomItemValidate.as_view(), name='api-bom-item-validate'), - re_path(r'^metadata/?', MetadataView.as_view(), {'model': BomItem}, name='api-bom-item-metadata'), - re_path(r'^.*$', BomDetail.as_view(), name='api-bom-item-detail'), - ])), - + path( + r'/', + include([ + re_path( + r'^validate/?', BomItemValidate.as_view(), name='api-bom-item-validate' + ), + re_path( + r'^metadata/?', + MetadataView.as_view(), + {'model': BomItem}, + name='api-bom-item-metadata', + ), + re_path(r'^.*$', BomDetail.as_view(), name='api-bom-item-detail'), + ]), + ), # API endpoint URLs for importing BOM data - re_path(r'^import/upload/', BomImportUpload.as_view(), name='api-bom-import-upload'), - re_path(r'^import/extract/', BomImportExtract.as_view(), name='api-bom-import-extract'), - re_path(r'^import/submit/', BomImportSubmit.as_view(), name='api-bom-import-submit'), - + re_path( + r'^import/upload/', BomImportUpload.as_view(), name='api-bom-import-upload' + ), + re_path( + r'^import/extract/', BomImportExtract.as_view(), name='api-bom-import-extract' + ), + re_path( + r'^import/submit/', BomImportSubmit.as_view(), name='api-bom-import-submit' + ), # Catch-all re_path(r'^.*$', BomList.as_view(), name='api-bom-list'), ] diff --git a/InvenTree/part/apps.py b/InvenTree/part/apps.py index 9e59236cea..550c18250a 100644 --- a/InvenTree/part/apps.py +++ b/InvenTree/part/apps.py @@ -5,14 +5,19 @@ import logging from django.apps import AppConfig from django.db.utils import OperationalError, ProgrammingError -from InvenTree.ready import (canAppAccessDatabase, isImportingData, - isInMainThread, isPluginRegistryLoaded) +from InvenTree.ready import ( + canAppAccessDatabase, + isImportingData, + isInMainThread, + isPluginRegistryLoaded, +) logger = logging.getLogger("inventree") class PartConfig(AppConfig): """Config class for the 'part' app""" + name = 'part' def ready(self): @@ -33,7 +38,9 @@ class PartConfig(AppConfig): from .models import BomItem try: - items = BomItem.objects.filter(part__trackable=False, sub_part__trackable=True) + items = BomItem.objects.filter( + part__trackable=False, sub_part__trackable=True + ) for item in items: logger.info("Marking part '%s' as trackable", item.part.name) @@ -59,7 +66,9 @@ class PartConfig(AppConfig): if items.count() > 0: # Find any pricing objects which have the 'scheduled_for_update' flag set - logger.info("Resetting update flags for %s pricing objects...", items.count()) + logger.info( + "Resetting update flags for %s pricing objects...", items.count() + ) for pricing in items: pricing.scheduled_for_update = False diff --git a/InvenTree/part/bom.py b/InvenTree/part/bom.py index 525e60987b..bbaeea4d42 100644 --- a/InvenTree/part/bom.py +++ b/InvenTree/part/bom.py @@ -8,8 +8,7 @@ from collections import OrderedDict from django.utils.translation import gettext as _ from company.models import ManufacturerPart, SupplierPart -from InvenTree.helpers import (DownloadFile, GetExportFormats, normalize, - str2bool) +from InvenTree.helpers import DownloadFile, GetExportFormats, normalize, str2bool from .admin import BomItemResource from .models import BomItem, BomItemSubstitute, Part @@ -31,10 +30,7 @@ def MakeBomTemplate(fmt): # This will then export just the row headers! query = BomItem.objects.filter(pk=None) - dataset = BomItemResource().export( - queryset=query, - importing=True - ) + dataset = BomItemResource().export(queryset=query, importing=True) data = dataset.export(fmt) @@ -43,7 +39,9 @@ def MakeBomTemplate(fmt): return DownloadFile(data, filename) -def ExportBom(part: Part, fmt='csv', cascade: bool = False, max_levels: int = None, **kwargs): +def ExportBom( + part: Part, fmt='csv', cascade: bool = False, max_levels: int = None, **kwargs +): """Export a BOM (Bill of Materials) for a given part. Args: @@ -80,7 +78,6 @@ def ExportBom(part: Part, fmt='csv', cascade: bool = False, max_levels: int = No def add_items(items, level, cascade=True): # Add items at a given layer for item in items: - item.level = str(int(level)) # Avoid circular BOM references @@ -98,9 +95,7 @@ def ExportBom(part: Part, fmt='csv', cascade: bool = False, max_levels: int = No add_items(top_level_items, 1, cascade) dataset = BomItemResource().export( - queryset=bom_items, - cascade=cascade, - include_pricing=pricing_data, + queryset=bom_items, cascade=cascade, include_pricing=pricing_data ) def add_columns_to_dataset(columns, column_size): @@ -122,7 +117,6 @@ def ExportBom(part: Part, fmt='csv', cascade: bool = False, max_levels: int = No for bom_item in bom_items: substitutes = BomItemSubstitute.objects.filter(bom_item=bom_item) for s_idx, substitute in enumerate(substitutes): - """Create substitute part name column""" name = f'{_("Substitute Part")}{s_idx + 1}' value = substitute.part.name @@ -164,7 +158,9 @@ def ExportBom(part: Part, fmt='csv', cascade: bool = False, max_levels: int = No parameter_cols[name] = {b_idx: value} # Add parameter columns to dataset - parameter_cols_ordered = OrderedDict(sorted(parameter_cols.items(), key=lambda x: x[0])) + parameter_cols_ordered = OrderedDict( + sorted(parameter_cols.items(), key=lambda x: x[0]) + ) add_columns_to_dataset(parameter_cols_ordered, len(bom_items)) if stock_data: @@ -180,7 +176,6 @@ def ExportBom(part: Part, fmt='csv', cascade: bool = False, max_levels: int = No stock_cols = {} for b_idx, bom_item in enumerate(bom_items): - stock_data = [] sub_part = bom_item.sub_part @@ -197,19 +192,13 @@ def ExportBom(part: Part, fmt='csv', cascade: bool = False, max_levels: int = No stock_data.append('') # Total "in stock" quantity for this part - stock_data.append( - str(normalize(sub_part.total_stock)) - ) + stock_data.append(str(normalize(sub_part.total_stock))) # Total "available stock" quantity for this part - stock_data.append( - str(normalize(sub_part.available_stock)) - ) + stock_data.append(str(normalize(sub_part.available_stock))) # Total "on order" quantity for this part - stock_data.append( - str(normalize(sub_part.on_order)) - ) + stock_data.append(str(normalize(sub_part.on_order))) for s_idx, header in enumerate(stock_headers): try: @@ -234,12 +223,12 @@ def ExportBom(part: Part, fmt='csv', cascade: bool = False, max_levels: int = No # Include manufacturer data for each BOM item if manufacturer_data: - # Filter manufacturer parts - manufacturer_parts = ManufacturerPart.objects.filter(part__pk=b_part.pk).prefetch_related('supplier_parts') + manufacturer_parts = ManufacturerPart.objects.filter( + part__pk=b_part.pk + ).prefetch_related('supplier_parts') for mp_idx, mp_part in enumerate(manufacturer_parts): - # Extract the "name" field of the Manufacturer (Company) if mp_part and mp_part.manufacturer: manufacturer_name = mp_part.manufacturer.name @@ -265,9 +254,7 @@ def ExportBom(part: Part, fmt='csv', cascade: bool = False, max_levels: int = No # We wish to include supplier data for this manufacturer part if supplier_data: - for sp_idx, sp_part in enumerate(mp_part.supplier_parts.all()): - supplier_parts_used.add(sp_part) if sp_part.supplier: @@ -281,11 +268,21 @@ def ExportBom(part: Part, fmt='csv', cascade: bool = False, max_levels: int = No supplier_sku = '' # Generate column names for this supplier - k_sup = str(_("Supplier")) + "_" + str(mp_idx) + "_" + str(sp_idx) - k_sku = str(_("SKU")) + "_" + str(mp_idx) + "_" + str(sp_idx) + k_sup = ( + str(_("Supplier")) + + "_" + + str(mp_idx) + + "_" + + str(sp_idx) + ) + k_sku = ( + str(_("SKU")) + "_" + str(mp_idx) + "_" + str(sp_idx) + ) try: - manufacturer_cols[k_sup].update({bom_idx: supplier_name}) + manufacturer_cols[k_sup].update({ + bom_idx: supplier_name + }) manufacturer_cols[k_sku].update({bom_idx: supplier_sku}) except KeyError: manufacturer_cols[k_sup] = {bom_idx: supplier_name} @@ -294,8 +291,9 @@ def ExportBom(part: Part, fmt='csv', cascade: bool = False, max_levels: int = No if supplier_data: # Add in any extra supplier parts, which are not associated with a manufacturer part - for sp_idx, sp_part in enumerate(SupplierPart.objects.filter(part__pk=b_part.pk)): - + for sp_idx, sp_part in enumerate( + SupplierPart.objects.filter(part__pk=b_part.pk) + ): if sp_part in supplier_parts_used: continue diff --git a/InvenTree/part/filters.py b/InvenTree/part/filters.py index e48909dd87..0877882142 100644 --- a/InvenTree/part/filters.py +++ b/InvenTree/part/filters.py @@ -19,18 +19,32 @@ Relevant PRs: from decimal import Decimal from django.db import models -from django.db.models import (Case, DecimalField, Exists, ExpressionWrapper, F, - FloatField, Func, IntegerField, OuterRef, Q, - Subquery, Value, When) +from django.db.models import ( + Case, + DecimalField, + Exists, + ExpressionWrapper, + F, + FloatField, + Func, + IntegerField, + OuterRef, + Q, + Subquery, + Value, + When, +) from django.db.models.functions import Coalesce from sql_util.utils import SubquerySum import part.models import stock.models -from InvenTree.status_codes import (BuildStatusGroups, - PurchaseOrderStatusGroups, - SalesOrderStatusGroups) +from InvenTree.status_codes import ( + BuildStatusGroups, + PurchaseOrderStatusGroups, + SalesOrderStatusGroups, +) def annotate_on_order_quantity(reference: str = ''): @@ -46,27 +60,28 @@ def annotate_on_order_quantity(reference: str = ''): # Filter only 'active' purhase orders # Filter only line with outstanding quantity order_filter = Q( - order__status__in=PurchaseOrderStatusGroups.OPEN, - quantity__gt=F('received'), + order__status__in=PurchaseOrderStatusGroups.OPEN, quantity__gt=F('received') ) return Coalesce( SubquerySum( ExpressionWrapper( - F(f'{reference}supplier_parts__purchase_order_line_items__quantity') * F(f'{reference}supplier_parts__pack_quantity_native'), + F(f'{reference}supplier_parts__purchase_order_line_items__quantity') + * F(f'{reference}supplier_parts__pack_quantity_native'), output_field=DecimalField(), ), - filter=order_filter + filter=order_filter, ), Decimal(0), - output_field=DecimalField() + output_field=DecimalField(), ) - Coalesce( SubquerySum( ExpressionWrapper( - F(f'{reference}supplier_parts__purchase_order_line_items__received') * F(f'{reference}supplier_parts__pack_quantity_native'), + F(f'{reference}supplier_parts__purchase_order_line_items__received') + * F(f'{reference}supplier_parts__pack_quantity_native'), output_field=DecimalField(), ), - filter=order_filter + filter=order_filter, ), Decimal(0), output_field=DecimalField(), @@ -88,10 +103,7 @@ def annotate_total_stock(reference: str = ''): stock_filter = stock.models.StockItem.IN_STOCK_FILTER return Coalesce( - SubquerySum( - f'{reference}stock_items__quantity', - filter=stock_filter, - ), + SubquerySum(f'{reference}stock_items__quantity', filter=stock_filter), Decimal(0), output_field=models.DecimalField(), ) @@ -109,10 +121,7 @@ def annotate_build_order_requirements(reference: str = ''): build_filter = Q(build__status__in=BuildStatusGroups.ACTIVE_CODES) return Coalesce( - SubquerySum( - f'{reference}used_in__build_lines__quantity', - filter=build_filter, - ), + SubquerySum(f'{reference}used_in__build_lines__quantity', filter=build_filter), Decimal(0), output_field=models.DecimalField(), ) @@ -134,8 +143,7 @@ def annotate_build_order_allocations(reference: str = ''): return Coalesce( SubquerySum( - f'{reference}stock_items__allocations__quantity', - filter=build_filter, + f'{reference}stock_items__allocations__quantity', filter=build_filter ), Decimal(0), output_field=models.DecimalField(), @@ -169,7 +177,9 @@ def annotate_sales_order_allocations(reference: str = ''): ) -def variant_stock_query(reference: str = '', filter: Q = stock.models.StockItem.IN_STOCK_FILTER): +def variant_stock_query( + reference: str = '', filter: Q = stock.models.StockItem.IN_STOCK_FILTER +): """Create a queryset to retrieve all stock items for variant parts under the specified part - Useful for annotating a queryset with aggregated information about variant parts @@ -221,10 +231,10 @@ def annotate_category_parts(): Subquery( subquery.annotate( total=Func(F('pk'), function='COUNT', output_field=IntegerField()) - ).values('total'), + ).values('total') ), 0, - output_field=IntegerField() + output_field=IntegerField(), ) @@ -259,31 +269,33 @@ def order_by_parameter(queryset, template_id: int, ascending=True): A queryset of Part objects ordered by the given parameter """ template_filter = part.models.PartParameter.objects.filter( - template__id=template_id, - part_id=OuterRef('id'), + template__id=template_id, part_id=OuterRef('id') ) # Annotate the queryset with the parameter value, and whether it exists - queryset = queryset.annotate( - parameter_exists=Exists(template_filter) - ) + queryset = queryset.annotate(parameter_exists=Exists(template_filter)) # Annotate the text data value queryset = queryset.annotate( parameter_value=Case( When( parameter_exists=True, - then=Subquery(template_filter.values('data')[:1], output_field=models.CharField()), + then=Subquery( + template_filter.values('data')[:1], output_field=models.CharField() + ), ), default=Value('', output_field=models.CharField()), ), parameter_value_numeric=Case( When( parameter_exists=True, - then=Subquery(template_filter.values('data_numeric')[:1], output_field=models.FloatField()), + then=Subquery( + template_filter.values('data_numeric')[:1], + output_field=models.FloatField(), + ), ), default=Value(0, output_field=models.FloatField()), - ) + ), ) prefix = '' if ascending else '-' diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index f1934bc105..b52f335d24 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -18,14 +18,16 @@ class BomMatchItemForm(MatchItemForm): if 'quantity' in col_guess.lower(): return forms.CharField( required=False, - widget=forms.NumberInput(attrs={ - 'name': 'quantity' + str(row['index']), - 'class': 'numberinput', - 'type': 'number', - 'min': '0', - 'step': 'any', - 'value': clean_decimal(row.get('quantity', '')), - }) + widget=forms.NumberInput( + attrs={ + 'name': 'quantity' + str(row['index']), + 'class': 'numberinput', + 'type': 'number', + 'min': '0', + 'step': 'any', + 'value': clean_decimal(row.get('quantity', '')), + } + ), ) return super().get_special_field(col_guess, row, file_manager) @@ -36,14 +38,13 @@ class PartPriceForm(forms.Form): class Meta: """Metaclass defines fields for this form""" + model = Part - fields = [ - 'quantity', - ] + fields = ['quantity'] quantity = forms.IntegerField( required=True, initial=1, label=_('Quantity'), - help_text=_('Input quantity for price calculation') + help_text=_('Input quantity for price calculation'), ) diff --git a/InvenTree/part/helpers.py b/InvenTree/part/helpers.py index ca1150b2ee..c95eaa83e4 100644 --- a/InvenTree/part/helpers.py +++ b/InvenTree/part/helpers.py @@ -29,7 +29,10 @@ def compile_full_name_template(*args, **kwargs): template_string = InvenTreeSetting.get_setting('PART_NAME_FORMAT', '') # Skip if the template string has not changed - if template_string == _part_full_name_template_string and _part_full_name_template is not None: + if ( + template_string == _part_full_name_template_string + and _part_full_name_template is not None + ): return _part_full_name_template # Cache the template string @@ -38,7 +41,7 @@ def compile_full_name_template(*args, **kwargs): env = Environment( autoescape=select_autoescape(default_for_string=False, default=False), variable_start_string='{{', - variable_end_string='}}' + variable_end_string='}}', ) # Compile the template @@ -65,7 +68,11 @@ def render_part_full_name(part) -> str: try: return template.render(part=part) except Exception as e: - logger.warning("exception while trying to create full name for part %s: %s", part.name, e) + logger.warning( + "exception while trying to create full name for part %s: %s", + part.name, + e, + ) # Fallback to the default format elements = [el for el in [part.IPN, part.name, part.revision] if el] @@ -85,10 +92,9 @@ def get_part_image_directory() -> str: TODO: Future work may be needed here to support other storage backends, such as S3 """ - part_image_directory = os.path.abspath(os.path.join( - settings.MEDIA_ROOT, - PART_IMAGE_DIR, - )) + part_image_directory = os.path.abspath( + os.path.join(settings.MEDIA_ROOT, PART_IMAGE_DIR) + ) # Create the directory if it does not exist if not os.path.exists(part_image_directory): diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index b3b409b5b1..cb10243719 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -48,14 +48,22 @@ from common.settings import currency_code_default from company.models import SupplierPart from InvenTree import helpers, validators from InvenTree.fields import InvenTreeURLField -from InvenTree.helpers import (decimal2money, decimal2string, normalize, - str2bool) -from InvenTree.models import (DataImportMixin, InvenTreeAttachment, - InvenTreeBarcodeMixin, InvenTreeNotesMixin, - InvenTreeTree, MetadataMixin) -from InvenTree.status_codes import (BuildStatusGroups, PurchaseOrderStatus, - PurchaseOrderStatusGroups, - SalesOrderStatus, SalesOrderStatusGroups) +from InvenTree.helpers import decimal2money, decimal2string, normalize, str2bool +from InvenTree.models import ( + DataImportMixin, + InvenTreeAttachment, + InvenTreeBarcodeMixin, + InvenTreeNotesMixin, + InvenTreeTree, + MetadataMixin, +) +from InvenTree.status_codes import ( + BuildStatusGroups, + PurchaseOrderStatus, + PurchaseOrderStatusGroups, + SalesOrderStatus, + SalesOrderStatusGroups, +) from order import models as OrderModels from stock import models as StockModels @@ -76,6 +84,7 @@ class PartCategory(MetadataMixin, InvenTreeTree): class Meta: """Metaclass defines extra model properties""" + verbose_name = _("Part Category") verbose_name_plural = _("Part Categories") @@ -91,11 +100,13 @@ class PartCategory(MetadataMixin, InvenTreeTree): ) default_location = TreeForeignKey( - 'stock.StockLocation', related_name="default_categories", - null=True, blank=True, + 'stock.StockLocation', + related_name="default_categories", + null=True, + blank=True, on_delete=models.SET_NULL, verbose_name=_('Default Location'), - help_text=_('Default location for parts in this category') + help_text=_('Default location for parts in this category'), ) structural = models.BooleanField( @@ -103,16 +114,23 @@ class PartCategory(MetadataMixin, InvenTreeTree): verbose_name=_('Structural'), help_text=_( 'Parts may not be directly assigned to a structural category, ' - 'but may be assigned to child categories.'), + 'but may be assigned to child categories.' + ), ) - default_keywords = models.CharField(null=True, blank=True, max_length=250, verbose_name=_('Default keywords'), help_text=_('Default keywords for parts in this category')) + default_keywords = models.CharField( + null=True, + blank=True, + max_length=250, + verbose_name=_('Default keywords'), + help_text=_('Default keywords for parts in this category'), + ) icon = models.CharField( blank=True, max_length=100, verbose_name=_("Icon"), - help_text=_("Icon (optional)") + help_text=_("Icon (optional)"), ) @staticmethod @@ -131,8 +149,11 @@ class PartCategory(MetadataMixin, InvenTreeTree): """ if self.pk and self.structural and self.partcount(False, False) > 0: raise ValidationError( - _("You cannot make this part category structural because some parts " - "are already assigned to it!")) + _( + "You cannot make this part category structural because some parts " + "are already assigned to it!" + ) + ) super().clean() def get_parts(self, cascade=True) -> set[Part]: @@ -146,7 +167,9 @@ class PartCategory(MetadataMixin, InvenTreeTree): """ if cascade: """Select any parts which exist in this category or any child categories.""" - queryset = Part.objects.filter(category__in=self.getUniqueChildren(include_self=True)) + queryset = Part.objects.filter( + category__in=self.getUniqueChildren(include_self=True) + ) else: queryset = Part.objects.filter(category=self.pk) @@ -172,7 +195,11 @@ class PartCategory(MetadataMixin, InvenTreeTree): def prefetch_parts_parameters(self, cascade=True): """Prefectch parts parameters.""" - return self.get_parts(cascade=cascade).prefetch_related('parameters', 'parameters__template').all() + return ( + self.get_parts(cascade=cascade) + .prefetch_related('parameters', 'parameters__template') + .all() + ) def get_unique_parameters(self, cascade=True, prefetch=None): """Get all unique parameter names for all parts from this category.""" @@ -233,7 +260,9 @@ class PartCategory(MetadataMixin, InvenTreeTree): def get_parameter_templates(self): """Return parameter templates associated to category.""" - prefetch = PartCategoryParameterTemplate.objects.prefetch_related('category', 'parameter_template') + prefetch = PartCategoryParameterTemplate.objects.prefetch_related( + 'category', 'parameter_template' + ) return prefetch.filter(category=self.id) @@ -244,13 +273,9 @@ class PartCategory(MetadataMixin, InvenTreeTree): subscribers = set() if include_parents: - queryset = PartCategoryStar.objects.filter( - category__in=cats, - ) + queryset = PartCategoryStar.objects.filter(category__in=cats) else: - queryset = PartCategoryStar.objects.filter( - category=self, - ) + queryset = PartCategoryStar.objects.filter(category=self) for result in queryset: subscribers.add(result.user) @@ -270,17 +295,11 @@ class PartCategory(MetadataMixin, InvenTreeTree): return if status: - PartCategoryStar.objects.create( - category=self, - user=user - ) + PartCategoryStar.objects.create(category=self, user=user) else: # Note that this won't actually stop the user being subscribed, # if the user is subscribed to a parent category - PartCategoryStar.objects.filter( - category=self, - user=user, - ).delete() + PartCategoryStar.objects.filter(category=self, user=user).delete() def rename_part_image(instance, filename): @@ -309,13 +328,17 @@ class PartManager(TreeManager): def get_queryset(self): """Perform default prefetch operations when accessing Part model from the database""" - return super().get_queryset().prefetch_related( - 'category', - 'pricing_data', - 'category__parent', - 'stock_items', - 'builds', - 'tags', + return ( + super() + .get_queryset() + .prefetch_related( + 'category', + 'pricing_data', + 'category__parent', + 'stock_items', + 'builds', + 'tags', + ) ) @@ -363,15 +386,17 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) class Meta: """Metaclass defines extra model properties""" + verbose_name = _("Part") verbose_name_plural = _("Parts") - ordering = ['name', ] + ordering = ['name'] constraints = [ UniqueConstraint(fields=['name', 'IPN', 'revision'], name='unique_part') ] class MPTTMeta: """MPTT metaclass definitions""" + # For legacy reasons the 'variant_of' field is used to indicate the MPTT parent parent_attr = 'variant_of' @@ -382,11 +407,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) def api_instance_filters(self): """Return API query filters for limiting field results against this instance.""" - return { - 'variant_of': { - 'exclude_tree': self.pk, - } - } + return {'variant_of': {'exclude_tree': self.pk}} def get_context_data(self, request, **kwargs): """Return some useful context data about this part for template rendering.""" @@ -397,9 +418,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) # Subscription status context['starred'] = self.is_starred_by(request.user) context['starred_directly'] = context['starred'] and self.is_starred_by( - request.user, - include_variants=False, - include_categories=False + request.user, include_variants=False, include_categories=False ) # Pre-calculate complex queries so they only need to be performed once @@ -411,13 +430,21 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) context['allocated_build_order_quantity'] = self.build_order_allocation_count() context['required_sales_order_quantity'] = self.required_sales_order_quantity() - context['allocated_sales_order_quantity'] = self.sales_order_allocation_count(pending=True) + context['allocated_sales_order_quantity'] = self.sales_order_allocation_count( + pending=True + ) context['available'] = self.available_stock context['on_order'] = self.on_order - context['required'] = context['required_build_order_quantity'] + context['required_sales_order_quantity'] - context['allocated'] = context['allocated_build_order_quantity'] + context['allocated_sales_order_quantity'] + context['required'] = ( + context['required_build_order_quantity'] + + context['required_sales_order_quantity'] + ) + context['allocated'] = ( + context['allocated_build_order_quantity'] + + context['allocated_sales_order_quantity'] + ) return context @@ -433,9 +460,12 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) # Image has been changed if previous.image is not None and self.image != previous.image: - # Are there any (other) parts which reference the image? - n_refs = Part.objects.filter(image=previous.image).exclude(pk=self.pk).count() + n_refs = ( + Part.objects.filter(image=previous.image) + .exclude(pk=self.pk) + .count() + ) if n_refs == 0: logger.info("Deleting unused image file '%s'", previous.image) @@ -448,9 +478,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) try: super().save(*args, **kwargs) except InvalidMove: - raise ValidationError({ - 'variant_of': _('Invalid choice for parent part'), - }) + raise ValidationError({'variant_of': _('Invalid choice for parent part')}) def __str__(self): """Return a string representation of the Part (for use in the admin interface)""" @@ -492,26 +520,35 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) try: if self.pk == parent.pk: - raise ValidationError({'sub_part': _(f"Part '{self}' cannot be used in BOM for '{parent}' (recursive)")}) + raise ValidationError({ + 'sub_part': _( + f"Part '{self}' cannot be used in BOM for '{parent}' (recursive)" + ) + }) if self.tree_id == parent.tree_id: - raise ValidationError({'sub_part': _(f"Part '{self}' cannot be used in BOM for '{parent}' (recursive)")}) + raise ValidationError({ + 'sub_part': _( + f"Part '{self}' cannot be used in BOM for '{parent}' (recursive)" + ) + }) bom_items = self.get_bom_items() # Ensure that the parent part does not appear under any child BOM item! for item in bom_items.all(): - # Check for simple match if item.sub_part == parent: - raise ValidationError({'sub_part': _(f"Part '{parent}' is used in BOM for '{self}' (recursive)")}) + raise ValidationError({ + 'sub_part': _( + f"Part '{parent}' is used in BOM for '{self}' (recursive)" + ) + }) # And recursively check too if recursive: result = result and item.sub_part.check_add_to_bom( - parent, - recursive=True, - raise_error=raise_error + parent, recursive=True, raise_error=raise_error ) except ValidationError as e: @@ -539,9 +576,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) return except ValidationError as exc: if raise_error: - raise ValidationError({ - 'name': exc.message, - }) + raise ValidationError({'name': exc.message}) def validate_ipn(self, raise_error=True): """Ensure that the IPN (internal part number) is valid for this Part" @@ -560,12 +595,12 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) break except ValidationError as exc: if raise_error: - raise ValidationError({ - 'IPN': exc.message - }) + raise ValidationError({'IPN': exc.message}) # If we get to here, none of the plugins have raised an error - pattern = common.models.InvenTreeSetting.get_setting('PART_IPN_REGEX', '', create=False).strip() + pattern = common.models.InvenTreeSetting.get_setting( + 'PART_IPN_REGEX', '', create=False + ).strip() if pattern: match = re.search(pattern, self.IPN) @@ -573,7 +608,14 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) if match is None: raise ValidationError(_(f'IPN must match regex pattern {pattern}')) - def validate_serial_number(self, serial: str, stock_item=None, check_duplicates=True, raise_error=False, **kwargs): + def validate_serial_number( + self, + serial: str, + stock_item=None, + check_duplicates=True, + raise_error=False, + **kwargs, + ): """Validate a serial number against this Part instance. Note: This function is exposed to any Validation plugins, and thus can be customized. @@ -626,7 +668,9 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) from part.models import Part from stock.models import StockItem - if common.models.InvenTreeSetting.get_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False): + if common.models.InvenTreeSetting.get_setting( + 'SERIAL_NUMBER_GLOBALLY_UNIQUE', False + ): # Serial number must be unique across *all* parts parts = Part.objects.all() else: @@ -641,7 +685,11 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) if stock.exists(): if raise_error: - raise ValidationError(_("Stock item with this serial number already exists") + ": " + serial) + raise ValidationError( + _("Stock item with this serial number already exists") + + ": " + + serial + ) else: return False else: @@ -671,10 +719,14 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) Returns: The latest serial number specified for this part, or None """ - stock = StockModels.StockItem.objects.all().exclude(serial=None).exclude(serial='') + stock = ( + StockModels.StockItem.objects.all().exclude(serial=None).exclude(serial='') + ) # Generate a query for any stock items for this part variant tree with non-empty serial numbers - if common.models.InvenTreeSetting.get_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False): + if common.models.InvenTreeSetting.get_setting( + 'SERIAL_NUMBER_GLOBALLY_UNIQUE', False + ): # Serial numbers are unique across all parts pass else: @@ -727,7 +779,9 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) super().validate_unique(exclude) # User can decide whether duplicate IPN (Internal Part Number) values are allowed - allow_duplicate_ipn = common.models.InvenTreeSetting.get_setting('PART_ALLOW_DUPLICATE_IPN') + allow_duplicate_ipn = common.models.InvenTreeSetting.get_setting( + 'PART_ALLOW_DUPLICATE_IPN' + ) # Raise an error if an IPN is set, and it is a duplicate if self.IPN and not allow_duplicate_ipn: @@ -736,12 +790,18 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) if parts.exists(): raise ValidationError({ - 'IPN': _('Duplicate IPN not allowed in part settings'), + 'IPN': _('Duplicate IPN not allowed in part settings') }) # Ensure unique across (Name, revision, IPN) (as specified) - if Part.objects.exclude(pk=self.pk).filter(name=self.name, revision=self.revision, IPN=self.IPN).exists(): - raise ValidationError(_("Part with this Name, IPN and Revision already exists.")) + if ( + Part.objects.exclude(pk=self.pk) + .filter(name=self.name, revision=self.revision, IPN=self.IPN) + .exists() + ): + raise ValidationError( + _("Part with this Name, IPN and Revision already exists.") + ) def clean(self): """Perform cleaning operations for the Part model. @@ -754,8 +814,9 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) then we will force the parent part to be trackable. """ if self.category is not None and self.category.structural: - raise ValidationError( - {'category': _("Parts cannot be assigned to structural part categories!")}) + raise ValidationError({ + 'category': _("Parts cannot be assigned to structural part categories!") + }) super().clean() @@ -771,81 +832,85 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) if self.trackable: for part in self.get_used_in(): - if not part.trackable: part.trackable = True part.clean() part.save() name = models.CharField( - max_length=100, blank=False, - help_text=_('Part name'), - verbose_name=_('Name'), + max_length=100, blank=False, help_text=_('Part name'), verbose_name=_('Name') ) is_template = models.BooleanField( default=part_settings.part_template_default, verbose_name=_('Is Template'), - help_text=_('Is this part a template part?') + help_text=_('Is this part a template part?'), ) variant_of = models.ForeignKey( - 'part.Part', related_name='variants', - null=True, blank=True, - limit_choices_to={ - 'is_template': True, - }, + 'part.Part', + related_name='variants', + null=True, + blank=True, + limit_choices_to={'is_template': True}, on_delete=models.SET_NULL, help_text=_('Is this part a variant of another part?'), verbose_name=_('Variant Of'), ) description = models.CharField( - max_length=250, blank=True, + max_length=250, + blank=True, verbose_name=_('Description'), - help_text=_('Part description (optional)') + help_text=_('Part description (optional)'), ) keywords = models.CharField( - max_length=250, blank=True, null=True, + max_length=250, + blank=True, + null=True, verbose_name=_('Keywords'), - help_text=_('Part keywords to improve visibility in search results') + help_text=_('Part keywords to improve visibility in search results'), ) category = TreeForeignKey( - PartCategory, related_name='parts', - null=True, blank=True, + PartCategory, + related_name='parts', + null=True, + blank=True, on_delete=models.DO_NOTHING, verbose_name=_('Category'), - help_text=_('Part category') + help_text=_('Part category'), ) IPN = models.CharField( - max_length=100, blank=True, null=True, + max_length=100, + blank=True, + null=True, verbose_name=_('IPN'), help_text=_('Internal Part Number'), ) revision = models.CharField( - max_length=100, blank=True, null=True, + max_length=100, + blank=True, + null=True, help_text=_('Part revision or version number'), verbose_name=_('Revision'), ) link = InvenTreeURLField( - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Link'), - help_text=_('Link to external URL') + help_text=_('Link to external URL'), ) image = StdImageField( upload_to=rename_part_image, null=True, blank=True, - variations={ - 'thumbnail': (128, 128), - 'preview': (256, 256), - }, + variations={'thumbnail': (128, 128), 'preview': (256, 256)}, delete_orphans=False, verbose_name=_('Image'), ) @@ -853,7 +918,8 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) default_location = TreeForeignKey( 'stock.StockLocation', on_delete=models.SET_NULL, - blank=True, null=True, + blank=True, + null=True, help_text=_('Where is this item normally stored?'), related_name='default_parts', verbose_name=_('Default Location'), @@ -898,10 +964,11 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) default_supplier = models.ForeignKey( SupplierPart, on_delete=models.SET_NULL, - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Default Supplier'), help_text=_('Default supplier part'), - related_name='default_parts' + related_name='default_parts', ) default_expiry = models.PositiveIntegerField( @@ -912,81 +979,113 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) ) minimum_stock = models.DecimalField( - max_digits=19, decimal_places=6, - default=0, validators=[MinValueValidator(0)], + max_digits=19, + decimal_places=6, + default=0, + validators=[MinValueValidator(0)], verbose_name=_('Minimum Stock'), - help_text=_('Minimum allowed stock level') + help_text=_('Minimum allowed stock level'), ) units = models.CharField( - max_length=20, default="", - blank=True, null=True, + max_length=20, + default="", + blank=True, + null=True, verbose_name=_('Units'), help_text=_('Units of measure for this part'), - validators=[ - validators.validate_physical_units, - ] + validators=[validators.validate_physical_units], ) assembly = models.BooleanField( default=part_settings.part_assembly_default, verbose_name=_('Assembly'), - help_text=_('Can this part be built from other parts?') + help_text=_('Can this part be built from other parts?'), ) component = models.BooleanField( default=part_settings.part_component_default, verbose_name=_('Component'), - help_text=_('Can this part be used to build other parts?') + help_text=_('Can this part be used to build other parts?'), ) trackable = models.BooleanField( default=part_settings.part_trackable_default, verbose_name=_('Trackable'), - help_text=_('Does this part have tracking for unique items?')) + help_text=_('Does this part have tracking for unique items?'), + ) purchaseable = models.BooleanField( default=part_settings.part_purchaseable_default, verbose_name=_('Purchaseable'), - help_text=_('Can this part be purchased from external suppliers?')) + help_text=_('Can this part be purchased from external suppliers?'), + ) salable = models.BooleanField( default=part_settings.part_salable_default, verbose_name=_('Salable'), - help_text=_("Can this part be sold to customers?")) + help_text=_("Can this part be sold to customers?"), + ) active = models.BooleanField( - default=True, - verbose_name=_('Active'), - help_text=_('Is this part active?')) + default=True, verbose_name=_('Active'), help_text=_('Is this part active?') + ) virtual = models.BooleanField( default=part_settings.part_virtual_default, verbose_name=_('Virtual'), - help_text=_('Is this a virtual part, such as a software product or license?')) + help_text=_('Is this a virtual part, such as a software product or license?'), + ) - bom_checksum = models.CharField(max_length=128, blank=True, verbose_name=_('BOM checksum'), help_text=_('Stored BOM checksum')) + bom_checksum = models.CharField( + max_length=128, + blank=True, + verbose_name=_('BOM checksum'), + help_text=_('Stored BOM checksum'), + ) - bom_checked_by = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, - verbose_name=_('BOM checked by'), related_name='boms_checked') + bom_checked_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_('BOM checked by'), + related_name='boms_checked', + ) - bom_checked_date = models.DateField(blank=True, null=True, verbose_name=_('BOM checked date')) + bom_checked_date = models.DateField( + blank=True, null=True, verbose_name=_('BOM checked date') + ) - creation_date = models.DateField(auto_now_add=True, editable=False, blank=True, null=True, verbose_name=_('Creation Date')) + creation_date = models.DateField( + auto_now_add=True, + editable=False, + blank=True, + null=True, + verbose_name=_('Creation Date'), + ) - creation_user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('Creation User'), related_name='parts_created') + creation_user = models.ForeignKey( + User, + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_('Creation User'), + related_name='parts_created', + ) responsible_owner = models.ForeignKey( - users.models.Owner, on_delete=models.SET_NULL, - blank=True, null=True, + users.models.Owner, + on_delete=models.SET_NULL, + blank=True, + null=True, verbose_name=_('Responsible'), help_text=_('Owner responsible for this part'), - related_name='parts_responsible' + related_name='parts_responsible', ) last_stocktake = models.DateField( - blank=True, null=True, - verbose_name=_('Last Stocktake'), + blank=True, null=True, verbose_name=_('Last Stocktake') ) @property @@ -1013,8 +1112,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) # Now, get a list of outstanding build orders which require this part builds = BuildModels.Build.objects.filter( - part__in=self.get_used_in(), - status__in=BuildStatusGroups.ACTIVE_CODES + part__in=self.get_used_in(), status__in=BuildStatusGroups.ACTIVE_CODES ) return builds @@ -1027,7 +1125,6 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) quantity = 0 for build in builds: - bom_item = None # List the bom lines required to make the build (including inherited ones!) @@ -1035,7 +1132,6 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) # Match BOM item to build for bom_item in bom_items: - build_quantity = build.quantity * bom_item.quantity quantity += build_quantity @@ -1048,8 +1144,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) # Get a list of line items for open orders which match this part open_lines = OrderModels.SalesOrderLineItem.objects.filter( - order__status__in=SalesOrderStatusGroups.OPEN, - part=self + order__status__in=SalesOrderStatusGroups.OPEN, part=self ) for line in open_lines: @@ -1061,8 +1156,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) """Return the quantity of this part required for active sales orders.""" # Get a list of line items for open orders which match this part open_lines = OrderModels.SalesOrderLineItem.objects.filter( - order__status__in=SalesOrderStatusGroups.OPEN, - part=self + order__status__in=SalesOrderStatusGroups.OPEN, part=self ) quantity = 0 @@ -1076,7 +1170,9 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) def required_order_quantity(self): """Return total required to fulfil orders.""" - return self.required_build_order_quantity() + self.required_sales_order_quantity() + return ( + self.required_build_order_quantity() + self.required_sales_order_quantity() + ) @property def quantity_to_order(self): @@ -1136,9 +1232,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) queryset = PartStar.objects.all() if include_variants: - queryset = queryset.filter( - part__in=self.get_ancestors(include_self=True), - ) + queryset = queryset.filter(part__in=self.get_ancestors(include_self=True)) else: queryset = queryset.filter(part=self) @@ -1146,7 +1240,6 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) subscribers.add(star.user) if include_categories and self.category: - for sub in self.category.get_subscribers(): subscribers.add(sub) @@ -1217,13 +1310,19 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) ref = 'substitutes__part__' queryset = queryset.alias( sub_total_stock=part.filters.annotate_total_stock(reference=ref), - sub_so_allocations=part.filters.annotate_sales_order_allocations(reference=ref), - sub_bo_allocations=part.filters.annotate_build_order_allocations(reference=ref), + sub_so_allocations=part.filters.annotate_sales_order_allocations( + reference=ref + ), + sub_bo_allocations=part.filters.annotate_build_order_allocations( + reference=ref + ), ) queryset = queryset.annotate( substitute_stock=ExpressionWrapper( - F('sub_total_stock') - F('sub_so_allocations') - F('sub_bo_allocations'), + F('sub_total_stock') + - F('sub_so_allocations') + - F('sub_bo_allocations'), output_field=models.DecimalField(), ) ) @@ -1232,20 +1331,27 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) variant_stock_query = part.filters.variant_stock_query(reference='sub_part__') queryset = queryset.alias( - var_total_stock=part.filters.annotate_variant_quantity(variant_stock_query, reference='quantity'), - var_bo_allocations=part.filters.annotate_variant_quantity(variant_stock_query, reference='allocations__quantity'), - var_so_allocations=part.filters.annotate_variant_quantity(variant_stock_query, reference='sales_order_allocations__quantity'), + var_total_stock=part.filters.annotate_variant_quantity( + variant_stock_query, reference='quantity' + ), + var_bo_allocations=part.filters.annotate_variant_quantity( + variant_stock_query, reference='allocations__quantity' + ), + var_so_allocations=part.filters.annotate_variant_quantity( + variant_stock_query, reference='sales_order_allocations__quantity' + ), ) queryset = queryset.annotate( variant_stock=ExpressionWrapper( - F('var_total_stock') - F('var_bo_allocations') - F('var_so_allocations'), + F('var_total_stock') + - F('var_bo_allocations') + - F('var_so_allocations'), output_field=models.DecimalField(), ) ) for item in queryset.all(): - if item.quantity <= 0: # Ignore zero-quantity items continue @@ -1299,9 +1405,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) if include_variants: variants = self.get_descendants(include_self=True) - queryset = queryset.filter( - stock_item__part__in=variants, - ) + queryset = queryset.filter(stock_item__part__in=variants) else: queryset = queryset.filter(stock_item__part=self) @@ -1311,10 +1415,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) """Return the total amount of this part allocated to build orders.""" query = self.build_order_allocations(**kwargs).aggregate( total=Coalesce( - Sum( - 'quantity', - output_field=models.DecimalField() - ), + Sum('quantity', output_field=models.DecimalField()), 0, output_field=models.DecimalField(), ) @@ -1331,9 +1432,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) if include_variants: # Include allocations for all variants variants = self.get_descendants(include_self=True) - queryset = queryset.filter( - item__part__in=variants, - ) + queryset = queryset.filter(item__part__in=variants) else: # Only look at this part queryset = queryset.filter(item__part=self) @@ -1360,10 +1459,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) """Return the total quantity of this part allocated to sales orders.""" query = self.sales_order_allocations(**kwargs).aggregate( total=Coalesce( - Sum( - 'quantity', - output_field=models.DecimalField(), - ), + Sum('quantity', output_field=models.DecimalField()), 0, output_field=models.DecimalField(), ) @@ -1377,12 +1473,10 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) # If this instance has not been saved, foreign-key lookups will fail return 0 - return sum( - [ - self.build_order_allocation_count(**kwargs), - self.sales_order_allocation_count(**kwargs), - ], - ) + return sum([ + self.build_order_allocation_count(**kwargs), + self.sales_order_allocation_count(**kwargs), + ]) def stock_entries(self, include_variants=True, in_stock=None, location=None): """Return all stock entries for this Part. @@ -1393,7 +1487,9 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) location: If set, filter by stock entries in the specified location """ if include_variants: - query = StockModels.StockItem.objects.filter(part__in=self.get_descendants(include_self=True)) + query = StockModels.StockItem.objects.filter( + part__in=self.get_descendants(include_self=True) + ) else: query = self.stock_items @@ -1448,11 +1544,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) # There are parents available if parents.exists(): - - parent_filter = Q( - part__in=parents, - inherited=True - ) + parent_filter = Q(part__in=parents, inherited=True) # OR the filters together bom_filter |= parent_filter @@ -1464,11 +1556,15 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) By default, will include inherited BOM items """ - queryset = BomItem.objects.filter(self.get_bom_item_filter(include_inherited=include_inherited)) + queryset = BomItem.objects.filter( + self.get_bom_item_filter(include_inherited=include_inherited) + ) return queryset.prefetch_related('sub_part') - def get_installed_part_options(self, include_inherited: bool = True, include_variants: bool = True): + def get_installed_part_options( + self, include_inherited: bool = True, include_variants: bool = True + ): """Return a set of all Parts which can be "installed" into this part, based on the BOM. Arguments: @@ -1478,7 +1574,6 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) parts = set() for bom_item in self.get_bom_items(include_inherited=include_inherited): - if include_variants and bom_item.allow_variants: for part in bom_item.sub_part.get_descendants(include_self=True): parts.add(part) @@ -1487,7 +1582,9 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) return parts - def get_used_in_bom_item_filter(self, include_variants=True, include_substitutes=True): + def get_used_in_bom_item_filter( + self, include_variants=True, include_substitutes=True + ): """Return a BomItem queryset which returns all BomItem instances which refer to *this* part. As the BOM allocation logic is somewhat complicted, there are some considerations: @@ -1506,26 +1603,18 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) parents = [] # Case A: This part is directly specified in a BomItem (we always use this case) - query = Q( - sub_part=self, - ) + query = Q(sub_part=self) if include_variants: # Case B: This part is a *variant* of a part which is specified in a BomItem which allows variants - query |= Q( - allow_variants=True, - sub_part__in=parents, - ) + query |= Q(allow_variants=True, sub_part__in=parents) # Case C: This part is a *substitute* of a part which is directly specified in a BomItem if include_substitutes: - # Grab a list of BomItem substitutes which reference this part substitutes = self.substitute_items.all() - query |= Q( - pk__in=[substitute.bom_item.pk for substitute in substitutes], - ) + query |= Q(pk__in=[substitute.bom_item.pk for substitute in substitutes]) return query @@ -1536,9 +1625,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) """ # Grab a queryset of all BomItem objects which "require" this part bom_items = BomItem.objects.filter( - self.get_used_in_bom_item_filter( - include_substitutes=include_substitutes - ) + self.get_used_in_bom_item_filter(include_substitutes=include_substitutes) ) # Iterate through the returned items and construct a set of @@ -1653,11 +1740,9 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) bom_items = self.get_bom_items() for bom_item in bom_items: - sub_part = bom_item.sub_part if sub_part not in parts: - parts.add(sub_part) if recursive: @@ -1756,7 +1841,6 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) max_price = None for supplier in self.supplier_parts.all(): - price = supplier.get_price(quantity) if price is None: @@ -1787,7 +1871,6 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) max_price = None for item in self.get_bom_items().select_related('sub_part'): - if item.sub_part.pk == self.pk: logger.warning("WARNING: BomItem ID %s contains itself in BOM", item.pk) continue @@ -1795,7 +1878,9 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) q = decimal.Decimal(quantity) i = decimal.Decimal(item.quantity) - prices = item.sub_part.get_price_range(q * i, internal=internal, purchase=purchase) + prices = item.sub_part.get_price_range( + q * i, internal=internal, purchase=purchase + ) if prices is None: continue @@ -1819,7 +1904,9 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) return (min_price, max_price) - def get_price_range(self, quantity=1, buy=True, bom=True, internal=False, purchase=False): + def get_price_range( + self, quantity=1, buy=True, bom=True, internal=False, purchase=False + ): """Return the price range for this part. This price can be either: @@ -1843,7 +1930,9 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) return purchase_price buy_price_range = self.get_supplier_price_range(quantity) if buy else None - bom_price_range = self.get_bom_price_range(quantity, internal=internal) if bom else None + bom_price_range = ( + self.get_bom_price_range(quantity, internal=internal) if bom else None + ) if buy_price_range is None: return bom_price_range @@ -1852,12 +1941,24 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) return buy_price_range return ( min(buy_price_range[0], bom_price_range[0]), - max(buy_price_range[1], bom_price_range[1]) + max(buy_price_range[1], bom_price_range[1]), ) - base_cost = models.DecimalField(max_digits=19, decimal_places=6, default=0, validators=[MinValueValidator(0)], verbose_name=_('base cost'), help_text=_('Minimum charge (e.g. stocking fee)')) + base_cost = models.DecimalField( + max_digits=19, + decimal_places=6, + default=0, + validators=[MinValueValidator(0)], + verbose_name=_('base cost'), + help_text=_('Minimum charge (e.g. stocking fee)'), + ) - multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], verbose_name=_('multiple'), help_text=_('Sell multiple')) + multiple = models.PositiveIntegerField( + default=1, + validators=[MinValueValidator(1)], + verbose_name=_('multiple'), + help_text=_('Sell multiple'), + ) get_price = common.models.get_price @@ -1887,15 +1988,13 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) if self.price_breaks.filter(quantity=quantity, part=self.pk).exists(): return - PartSellPriceBreak.objects.create( - part=self, - quantity=quantity, - price=price - ) + PartSellPriceBreak.objects.create(part=self, quantity=quantity, price=price) def get_internal_price(self, quantity, moq=True, multiples=True, currency=None): """Return the internal price of this Part at the specified quantity""" - return common.models.get_price(self, quantity, moq, multiples, currency, break_name='internal_price_breaks') + return common.models.get_price( + self, quantity, moq, multiples, currency, break_name='internal_price_breaks' + ) @property def has_internal_price_breaks(self): @@ -1915,7 +2014,11 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) """ currency = currency_code_default() try: - prices = [convert_money(item.purchase_price, currency).amount for item in self.stock_items.all() if item.purchase_price] + prices = [ + convert_money(item.purchase_price, currency).amount + for item in self.stock_items.all() + if item.purchase_price + ] except MissingRate: prices = None @@ -1995,14 +2098,15 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) self.get_parameters().delete() for parameter in other.get_parameters(): - # If this part already has a parameter pointing to the same template, # delete that parameter from this part first! try: - existing = PartParameter.objects.get(part=self, template=parameter.template) + existing = PartParameter.objects.get( + part=self, template=parameter.template + ) existing.delete() - except (PartParameter.DoesNotExist): + except PartParameter.DoesNotExist: pass parameter.part = self @@ -2020,7 +2124,9 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) include_parent: Set to True to traverse upwards """ if include_parent: - tests = PartTestTemplate.objects.filter(part__in=self.get_ancestors(include_self=True)) + tests = PartTestTemplate.objects.filter( + part__in=self.get_ancestors(include_self=True) + ) else: tests = self.test_templates @@ -2074,7 +2180,9 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) """Return a list of purchase orders which reference this part.""" orders = [] - for part in self.supplier_parts.all().prefetch_related('purchase_order_line_items'): + for part in self.supplier_parts.all().prefetch_related( + 'purchase_order_line_items' + ): for order in part.purchase_orders(): if order not in orders: orders.append(order) @@ -2092,7 +2200,6 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) # Iterate through all supplier parts for sp in self.supplier_parts.all(): - # Look at any incomplete line item for open orders lines = sp.purchase_order_line_items.filter( order__status__in=PurchaseOrderStatusGroups.OPEN, @@ -2191,10 +2298,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) # Ensure this part is not in the queryset, somehow filtered_parts = filtered_parts.exclude(pk=self.pk) - filtered_parts = filtered_parts.filter( - active=True, - virtual=False, - ) + filtered_parts = filtered_parts.filter(active=True, virtual=False) return filtered_parts @@ -2238,16 +2342,16 @@ def after_save_part(sender, instance: Part, created, **kwargs): # Run this check in the background try: - InvenTree.tasks.offload_task(part_tasks.notify_low_stock_if_required, instance) + InvenTree.tasks.offload_task( + part_tasks.notify_low_stock_if_required, instance + ) except PicklingError: # Can sometimes occur if the referenced Part has issues pass # Schedule a background task to rebuild any supplier parts InvenTree.tasks.offload_task( - part_tasks.rebuild_supplier_parts, - instance.pk, - force_async=True + part_tasks.rebuild_supplier_parts, instance.pk, force_async=True ) @@ -2297,7 +2401,11 @@ class PartPricing(common.models.MetaMixin): try: result = convert_money(money, target_currency) except MissingRate: - logger.warning("No currency conversion rate available for %s -> %s", money.currency, target_currency) + logger.warning( + "No currency conversion rate available for %s -> %s", + money.currency, + target_currency, + ) result = None return result @@ -2318,8 +2426,14 @@ class PartPricing(common.models.MetaMixin): if InvenTree.ready.isRunningMigrations(): return - if not self.part or not self.part.pk or not Part.objects.filter(pk=self.part.pk).exists(): - logger.warning("Referenced part instance does not exist - skipping pricing update.") + if ( + not self.part + or not self.part.pk + or not Part.objects.filter(pk=self.part.pk).exists() + ): + logger.warning( + "Referenced part instance does not exist - skipping pricing update." + ) return try: @@ -2327,7 +2441,9 @@ class PartPricing(common.models.MetaMixin): self.refresh_from_db() except (PartPricing.DoesNotExist, IntegrityError): # Error thrown if this PartPricing instance has already been removed - logger.warning("Error refreshing PartPricing instance for part '%s'", self.part) + logger.warning( + "Error refreshing PartPricing instance for part '%s'", self.part + ) return # Ensure that the referenced part still exists in the database @@ -2335,7 +2451,9 @@ class PartPricing(common.models.MetaMixin): p = self.part p.refresh_from_db() except IntegrityError: - logger.exception("Could not update PartPricing as Part '%s' does not exist", self.part) + logger.exception( + "Could not update PartPricing as Part '%s' does not exist", self.part + ) return if self.scheduled_for_update: @@ -2345,7 +2463,9 @@ class PartPricing(common.models.MetaMixin): if counter > 25: # Prevent infinite recursion / stack depth issues - logger.debug(counter, f"Skipping pricing update for {p} - maximum depth exceeded") + logger.debug( + counter, f"Skipping pricing update for {p} - maximum depth exceeded" + ) return try: @@ -2353,7 +2473,9 @@ class PartPricing(common.models.MetaMixin): self.save() except IntegrityError: # An IntegrityError here likely indicates that the referenced part has already been deleted - logger.exception("Could not save PartPricing for part '%s' to the database", self.part) + logger.exception( + "Could not save PartPricing for part '%s' to the database", self.part + ) return import part.tasks as part_tasks @@ -2361,10 +2483,7 @@ class PartPricing(common.models.MetaMixin): # Offload task to update the pricing # Force async, to prevent running in the foreground InvenTree.tasks.offload_task( - part_tasks.update_part_pricing, - self, - counter=counter, - force_async=True + part_tasks.update_part_pricing, self, counter=counter, force_async=True ) def update_pricing(self, counter: int = 0, cascade: bool = True): @@ -2515,7 +2634,10 @@ class PartPricing(common.models.MetaMixin): else: self.bom_cost_max = None - if old_bom_cost_min != self.bom_cost_min or old_bom_cost_max != self.bom_cost_max: + if ( + old_bom_cost_min != self.bom_cost_min + or old_bom_cost_max != self.bom_cost_max + ): self.price_modified = True if save: @@ -2540,12 +2662,13 @@ class PartPricing(common.models.MetaMixin): purchase_max = None for line in line_items: - if line.purchase_price is None: continue # Take supplier part pack size into account - purchase_cost = self.convert(line.purchase_price / line.part.pack_quantity_native) + purchase_cost = self.convert( + line.purchase_price / line.part.pack_quantity_native + ) if purchase_cost is None: continue @@ -2558,11 +2681,14 @@ class PartPricing(common.models.MetaMixin): # Also check if manual stock item pricing is included if InvenTreeSetting.get_setting('PRICING_USE_STOCK_PRICING', True, cache=False): - items = self.part.stock_items.all() # Limit to stock items updated within a certain window - days = int(InvenTreeSetting.get_setting('PRICING_STOCK_ITEM_AGE_DAYS', 0, cache=False)) + days = int( + InvenTreeSetting.get_setting( + 'PRICING_STOCK_ITEM_AGE_DAYS', 0, cache=False + ) + ) if days > 0: date_threshold = datetime.now().date() - timedelta(days=days) @@ -2581,7 +2707,10 @@ class PartPricing(common.models.MetaMixin): if purchase_max is None or cost > purchase_max: purchase_max = cost - if self.purchase_cost_min != purchase_min or self.purchase_cost_max != purchase_max: + if ( + self.purchase_cost_min != purchase_min + or self.purchase_cost_max != purchase_max + ): self.price_modified = True self.purchase_cost_min = purchase_min @@ -2610,7 +2739,10 @@ class PartPricing(common.models.MetaMixin): if max_int_cost is None or cost > max_int_cost: max_int_cost = cost - if self.internal_cost_min != min_int_cost or self.internal_cost_max != max_int_cost: + if ( + self.internal_cost_min != min_int_cost + or self.internal_cost_max != max_int_cost + ): self.price_modified = True self.internal_cost_min = min_int_cost @@ -2629,13 +2761,10 @@ class PartPricing(common.models.MetaMixin): max_sup_cost = None if self.part.purchaseable: - # Iterate through each available SupplierPart instance for sp in self.part.supplier_parts.all(): - # Iterate through each available SupplierPriceBreak instance for pb in sp.pricebreaks.all(): - if pb.price is None: continue @@ -2651,7 +2780,10 @@ class PartPricing(common.models.MetaMixin): if max_sup_cost is None or cost > max_sup_cost: max_sup_cost = cost - if self.supplier_price_min != min_sup_cost or self.supplier_price_max != max_sup_cost: + if ( + self.supplier_price_min != min_sup_cost + or self.supplier_price_max != max_sup_cost + ): self.price_modified = True self.supplier_price_min = min_sup_cost @@ -2674,7 +2806,6 @@ class PartPricing(common.models.MetaMixin): variants = self.part.get_descendants(include_self=False) for v in variants: - if active_only and not v.active: # Ignore inactive variant parts continue @@ -2708,21 +2839,17 @@ class PartPricing(common.models.MetaMixin): overall_min = None overall_max = None - min_costs = [ - self.bom_cost_min, - self.purchase_cost_min, - self.internal_cost_min, - ] + min_costs = [self.bom_cost_min, self.purchase_cost_min, self.internal_cost_min] - max_costs = [ - self.bom_cost_max, - self.purchase_cost_max, - self.internal_cost_max, - ] + max_costs = [self.bom_cost_max, self.purchase_cost_max, self.internal_cost_max] - purchase_history_override = InvenTreeSetting.get_setting('PRICING_PURCHASE_HISTORY_OVERRIDES_SUPPLIER', False, cache=False) + purchase_history_override = InvenTreeSetting.get_setting( + 'PRICING_PURCHASE_HISTORY_OVERRIDES_SUPPLIER', False, cache=False + ) - if InvenTreeSetting.get_setting('PRICING_USE_SUPPLIER_PRICING', True, cache=False): + if InvenTreeSetting.get_setting( + 'PRICING_USE_SUPPLIER_PRICING', True, cache=False + ): # Add supplier pricing data, *unless* historical pricing information should override if self.purchase_cost_min is None or not purchase_history_override: min_costs.append(self.supplier_price_min) @@ -2730,7 +2857,9 @@ class PartPricing(common.models.MetaMixin): if self.purchase_cost_max is None or not purchase_history_override: max_costs.append(self.supplier_price_max) - if InvenTreeSetting.get_setting('PRICING_USE_VARIANT_PRICING', True, cache=False): + if InvenTreeSetting.get_setting( + 'PRICING_USE_VARIANT_PRICING', True, cache=False + ): # Include variant pricing in overall calculations min_costs.append(self.variant_cost_min) max_costs.append(self.variant_cost_max) @@ -2757,7 +2886,9 @@ class PartPricing(common.models.MetaMixin): if overall_max is None or cost > overall_max: overall_max = cost - if InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False, cache=False): + if InvenTreeSetting.get_setting( + 'PART_BOM_USE_INTERNAL_PRICE', False, cache=False + ): # Check if internal pricing should override other pricing if self.internal_cost_min is not None: overall_min = self.internal_cost_min @@ -2782,7 +2913,6 @@ class PartPricing(common.models.MetaMixin): max_sell_price = None for pb in self.part.salepricebreaks.all(): - cost = self.convert(pb.price) if cost is None: @@ -2803,15 +2933,13 @@ class PartPricing(common.models.MetaMixin): # Find all line items for shipped sales orders which reference this part line_items = OrderModels.SalesOrderLineItem.objects.filter( - order__status=SalesOrderStatus.SHIPPED, - part=self.part + order__status=SalesOrderStatus.SHIPPED, part=self.part ) # Exclude line items which do not have associated pricing data line_items = line_items.exclude(sale_price=None) for line in line_items: - cost = self.convert(line.sale_price) if cost is None: @@ -2823,7 +2951,10 @@ class PartPricing(common.models.MetaMixin): if max_sell_history is None or cost > max_sell_history: max_sell_history = cost - if self.sale_history_min != min_sell_history or self.sale_history_max != max_sell_history: + if ( + self.sale_history_min != min_sell_history + or self.sale_history_max != max_sell_history + ): self.price_modified = True self.sale_history_min = min_sell_history @@ -2840,9 +2971,7 @@ class PartPricing(common.models.MetaMixin): choices=common.settings.currency_code_mappings(), ) - scheduled_for_update = models.BooleanField( - default=False, - ) + scheduled_for_update = models.BooleanField(default=False) part = models.OneToOneField( Part, @@ -2852,109 +2981,127 @@ class PartPricing(common.models.MetaMixin): ) bom_cost_min = InvenTree.fields.InvenTreeModelMoneyField( - null=True, blank=True, + null=True, + blank=True, verbose_name=_('Minimum BOM Cost'), - help_text=_('Minimum cost of component parts') + help_text=_('Minimum cost of component parts'), ) bom_cost_max = InvenTree.fields.InvenTreeModelMoneyField( - null=True, blank=True, + null=True, + blank=True, verbose_name=_('Maximum BOM Cost'), help_text=_('Maximum cost of component parts'), ) purchase_cost_min = InvenTree.fields.InvenTreeModelMoneyField( - null=True, blank=True, + null=True, + blank=True, verbose_name=_('Minimum Purchase Cost'), help_text=_('Minimum historical purchase cost'), ) purchase_cost_max = InvenTree.fields.InvenTreeModelMoneyField( - null=True, blank=True, + null=True, + blank=True, verbose_name=_('Maximum Purchase Cost'), help_text=_('Maximum historical purchase cost'), ) internal_cost_min = InvenTree.fields.InvenTreeModelMoneyField( - null=True, blank=True, + null=True, + blank=True, verbose_name=_('Minimum Internal Price'), help_text=_('Minimum cost based on internal price breaks'), ) internal_cost_max = InvenTree.fields.InvenTreeModelMoneyField( - null=True, blank=True, + null=True, + blank=True, verbose_name=_('Maximum Internal Price'), help_text=_('Maximum cost based on internal price breaks'), ) supplier_price_min = InvenTree.fields.InvenTreeModelMoneyField( - null=True, blank=True, + null=True, + blank=True, verbose_name=_('Minimum Supplier Price'), help_text=_('Minimum price of part from external suppliers'), ) supplier_price_max = InvenTree.fields.InvenTreeModelMoneyField( - null=True, blank=True, + null=True, + blank=True, verbose_name=_('Maximum Supplier Price'), help_text=_('Maximum price of part from external suppliers'), ) variant_cost_min = InvenTree.fields.InvenTreeModelMoneyField( - null=True, blank=True, + null=True, + blank=True, verbose_name=_('Minimum Variant Cost'), help_text=_('Calculated minimum cost of variant parts'), ) variant_cost_max = InvenTree.fields.InvenTreeModelMoneyField( - null=True, blank=True, + null=True, + blank=True, verbose_name=_('Maximum Variant Cost'), help_text=_('Calculated maximum cost of variant parts'), ) override_min = InvenTree.fields.InvenTreeModelMoneyField( - null=True, blank=True, + null=True, + blank=True, verbose_name=_('Minimum Cost'), help_text=_('Override minimum cost'), ) override_max = InvenTree.fields.InvenTreeModelMoneyField( - null=True, blank=True, + null=True, + blank=True, verbose_name=_('Maximum Cost'), help_text=_('Override maximum cost'), ) overall_min = InvenTree.fields.InvenTreeModelMoneyField( - null=True, blank=True, + null=True, + blank=True, verbose_name=_('Minimum Cost'), help_text=_('Calculated overall minimum cost'), ) overall_max = InvenTree.fields.InvenTreeModelMoneyField( - null=True, blank=True, + null=True, + blank=True, verbose_name=_('Maximum Cost'), help_text=_('Calculated overall maximum cost'), ) sale_price_min = InvenTree.fields.InvenTreeModelMoneyField( - null=True, blank=True, + null=True, + blank=True, verbose_name=_('Minimum Sale Price'), help_text=_('Minimum sale price based on price breaks'), ) sale_price_max = InvenTree.fields.InvenTreeModelMoneyField( - null=True, blank=True, + null=True, + blank=True, verbose_name=_('Maximum Sale Price'), help_text=_('Maximum sale price based on price breaks'), ) sale_history_min = InvenTree.fields.InvenTreeModelMoneyField( - null=True, blank=True, + null=True, + blank=True, verbose_name=_('Minimum Sale Cost'), help_text=_('Minimum historical sale price'), ) sale_history_max = InvenTree.fields.InvenTreeModelMoneyField( - null=True, blank=True, + null=True, + blank=True, verbose_name=_('Maximum Sale Cost'), help_text=_('Maximum historical sale price'), ) @@ -2985,7 +3132,8 @@ class PartStocktake(models.Model): ) quantity = models.DecimalField( - max_digits=19, decimal_places=5, + max_digits=19, + decimal_places=5, validators=[MinValueValidator(0)], verbose_name=_('Quantity'), help_text=_('Total available stock at time of stocktake'), @@ -2994,7 +3142,7 @@ class PartStocktake(models.Model): date = models.DateField( verbose_name=_('Date'), help_text=_('Date stocktake was performed'), - auto_now_add=True + auto_now_add=True, ) note = models.CharField( @@ -3005,7 +3153,9 @@ class PartStocktake(models.Model): ) user = models.ForeignKey( - User, blank=True, null=True, + User, + blank=True, + null=True, on_delete=models.SET_NULL, related_name='part_stocktakes', verbose_name=_('User'), @@ -3013,13 +3163,15 @@ class PartStocktake(models.Model): ) cost_min = InvenTree.fields.InvenTreeModelMoneyField( - null=True, blank=True, + null=True, + blank=True, verbose_name=_('Minimum Stock Cost'), help_text=_('Estimated minimum cost of stock on hand'), ) cost_max = InvenTree.fields.InvenTreeModelMoneyField( - null=True, blank=True, + null=True, + blank=True, verbose_name=_('Maximum Stock Cost'), help_text=_('Estimated maximum cost of stock on hand'), ) @@ -3071,14 +3223,12 @@ class PartStocktakeReport(models.Model): return self.report.url return None - date = models.DateField( - verbose_name=_('Date'), - auto_now_add=True - ) + date = models.DateField(verbose_name=_('Date'), auto_now_add=True) report = models.FileField( upload_to=save_stocktake_report, - unique=False, blank=False, + unique=False, + blank=False, verbose_name=_('Report'), help_text=_('Stocktake report file (generated internally)'), ) @@ -3090,7 +3240,9 @@ class PartStocktakeReport(models.Model): ) user = models.ForeignKey( - User, blank=True, null=True, + User, + blank=True, + null=True, on_delete=models.SET_NULL, related_name='stocktake_reports', verbose_name=_('User'), @@ -3110,8 +3262,12 @@ class PartAttachment(InvenTreeAttachment): """Returns the media subdirectory where part attachments are stored""" return os.path.join("part_files", str(self.part.id)) - part = models.ForeignKey(Part, on_delete=models.CASCADE, - verbose_name=_('Part'), related_name='attachments') + part = models.ForeignKey( + Part, + on_delete=models.CASCADE, + verbose_name=_('Part'), + related_name='attachments', + ) class PartSellPriceBreak(common.models.PriceBreak): @@ -3119,6 +3275,7 @@ class PartSellPriceBreak(common.models.PriceBreak): class Meta: """Metaclass providing extra model definition""" + unique_together = ('part', 'quantity') @staticmethod @@ -3127,10 +3284,11 @@ class PartSellPriceBreak(common.models.PriceBreak): return reverse('api-part-sale-price-list') part = models.ForeignKey( - Part, on_delete=models.CASCADE, + Part, + on_delete=models.CASCADE, related_name='salepricebreaks', limit_choices_to={'salable': True}, - verbose_name=_('Part') + verbose_name=_('Part'), ) @@ -3139,6 +3297,7 @@ class PartInternalPriceBreak(common.models.PriceBreak): class Meta: """Metaclass providing extra model definition""" + unique_together = ('part', 'quantity') @staticmethod @@ -3147,9 +3306,10 @@ class PartInternalPriceBreak(common.models.PriceBreak): return reverse('api-part-internal-price-list') part = models.ForeignKey( - Part, on_delete=models.CASCADE, + Part, + on_delete=models.CASCADE, related_name='internalpricebreaks', - verbose_name=_('Part') + verbose_name=_('Part'), ) @@ -3165,14 +3325,22 @@ class PartStar(models.Model): class Meta: """Metaclass providing extra model definition""" - unique_together = [ - 'part', - 'user' - ] - part = models.ForeignKey(Part, on_delete=models.CASCADE, verbose_name=_('Part'), related_name='starred_users') + unique_together = ['part', 'user'] - user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('User'), related_name='starred_parts') + part = models.ForeignKey( + Part, + on_delete=models.CASCADE, + verbose_name=_('Part'), + related_name='starred_users', + ) + + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + verbose_name=_('User'), + related_name='starred_parts', + ) class PartCategoryStar(models.Model): @@ -3185,14 +3353,22 @@ class PartCategoryStar(models.Model): class Meta: """Metaclass providing extra model definition""" - unique_together = [ - 'category', - 'user', - ] - category = models.ForeignKey(PartCategory, on_delete=models.CASCADE, verbose_name=_('Category'), related_name='starred_users') + unique_together = ['category', 'user'] - user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('User'), related_name='starred_categories') + category = models.ForeignKey( + PartCategory, + on_delete=models.CASCADE, + verbose_name=_('Category'), + related_name='starred_users', + ) + + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + verbose_name=_('User'), + related_name='starred_categories', + ) class PartTestTemplate(MetadataMixin, models.Model): @@ -3266,33 +3442,38 @@ class PartTestTemplate(MetadataMixin, models.Model): ) test_name = models.CharField( - blank=False, max_length=100, + blank=False, + max_length=100, verbose_name=_("Test Name"), - help_text=_("Enter a name for the test") + help_text=_("Enter a name for the test"), ) description = models.CharField( - blank=False, null=True, max_length=100, + blank=False, + null=True, + max_length=100, verbose_name=_("Test Description"), - help_text=_("Enter description for this test") + help_text=_("Enter description for this test"), ) required = models.BooleanField( default=True, verbose_name=_("Required"), - help_text=_("Is this test required to pass?") + help_text=_("Is this test required to pass?"), ) requires_value = models.BooleanField( default=False, verbose_name=_("Requires Value"), - help_text=_("Does this test require a value when adding a test result?") + help_text=_("Does this test require a value when adding a test result?"), ) requires_attachment = models.BooleanField( default=False, verbose_name=_("Requires Attachment"), - help_text=_("Does this test require a file attachment when adding a test result?") + help_text=_( + "Does this test require a file attachment when adding a test result?" + ), ) @@ -3359,9 +3540,7 @@ class PartParameterTemplate(MetadataMixin, models.Model): continue if choice in choice_set: - raise ValidationError({ - 'choices': _('Choices must be unique') - }) + raise ValidationError({'choices': _('Choices must be unique')}) choice_set.add(choice) @@ -3373,7 +3552,9 @@ class PartParameterTemplate(MetadataMixin, models.Model): super().validate_unique(exclude) try: - others = PartParameterTemplate.objects.filter(name__iexact=self.name).exclude(pk=self.pk) + others = PartParameterTemplate.objects.filter( + name__iexact=self.name + ).exclude(pk=self.pk) if others.exists(): msg = _("Parameter template name must be unique") @@ -3392,16 +3573,15 @@ class PartParameterTemplate(MetadataMixin, models.Model): max_length=100, verbose_name=_('Name'), help_text=_('Parameter Name'), - unique=True + unique=True, ) units = models.CharField( max_length=25, - verbose_name=_('Units'), help_text=_('Physical units for this parameter'), + verbose_name=_('Units'), + help_text=_('Physical units for this parameter'), blank=True, - validators=[ - validators.validate_physical_units, - ] + validators=[validators.validate_physical_units], ) description = models.CharField( @@ -3414,7 +3594,7 @@ class PartParameterTemplate(MetadataMixin, models.Model): checkbox = models.BooleanField( default=False, verbose_name=_('Checkbox'), - help_text=_('Is this parameter a checkbox?') + help_text=_('Is this parameter a checkbox?'), ) choices = models.CharField( @@ -3425,19 +3605,20 @@ class PartParameterTemplate(MetadataMixin, models.Model): ) -@receiver(post_save, sender=PartParameterTemplate, dispatch_uid='post_save_part_parameter_template') +@receiver( + post_save, + sender=PartParameterTemplate, + dispatch_uid='post_save_part_parameter_template', +) def post_save_part_parameter_template(sender, instance, created, **kwargs): """Callback function when a PartParameterTemplate is created or saved""" import part.tasks as part_tasks if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData(): - if not created: # Schedule a background task to rebuild the parameters against this template InvenTree.tasks.offload_task( - part_tasks.rebuild_parameters, - instance.pk, - force_async=True + part_tasks.rebuild_parameters, instance.pk, force_async=True ) @@ -3452,6 +3633,7 @@ class PartParameter(MetadataMixin, models.Model): class Meta: """Metaclass providing extra model definition""" + # Prevent multiple instances of a parameter for a single part unique_together = ('part', 'template') @@ -3481,21 +3663,21 @@ class PartParameter(MetadataMixin, models.Model): super().clean() # Validate the parameter data against the template units - if InvenTreeSetting.get_setting('PART_PARAMETER_ENFORCE_UNITS', True, cache=False, create=False): + if InvenTreeSetting.get_setting( + 'PART_PARAMETER_ENFORCE_UNITS', True, cache=False, create=False + ): if self.template.units: try: - InvenTree.conversion.convert_physical_value(self.data, self.template.units) + InvenTree.conversion.convert_physical_value( + self.data, self.template.units + ) except ValidationError as e: - raise ValidationError({ - 'data': e.message - }) + raise ValidationError({'data': e.message}) # Validate the parameter data against the template choices if choices := self.template.get_choices(): if self.data not in choices: - raise ValidationError({ - 'data': _('Invalid choice for parameter value') - }) + raise ValidationError({'data': _('Invalid choice for parameter value')}) self.calculate_numeric_value() @@ -3503,7 +3685,6 @@ class PartParameter(MetadataMixin, models.Model): from plugin.registry import registry for plugin in registry.with_mixin('validation'): - # Note: The validate_part_parameter function may raise a ValidationError try: result = plugin.validate_part_parameter(self, self.data) @@ -3511,9 +3692,7 @@ class PartParameter(MetadataMixin, models.Model): break except ValidationError as exc: # Re-throw the ValidationError against the 'data' field - raise ValidationError({ - 'data': exc.message - }) + raise ValidationError({'data': exc.message}) def calculate_numeric_value(self): """Calculate a numeric value for the parameter data. @@ -3523,7 +3702,9 @@ class PartParameter(MetadataMixin, models.Model): """ if self.template.units: try: - self.data_numeric = InvenTree.conversion.convert_physical_value(self.data, self.template.units) + self.data_numeric = InvenTree.conversion.convert_physical_value( + self.data, self.template.units + ) except (ValidationError, ValueError): self.data_numeric = None @@ -3535,28 +3716,29 @@ class PartParameter(MetadataMixin, models.Model): self.data_numeric = None part = models.ForeignKey( - Part, on_delete=models.CASCADE, related_name='parameters', - verbose_name=_('Part'), help_text=_('Parent Part') + Part, + on_delete=models.CASCADE, + related_name='parameters', + verbose_name=_('Part'), + help_text=_('Parent Part'), ) template = models.ForeignKey( - PartParameterTemplate, on_delete=models.CASCADE, related_name='instances', - verbose_name=_('Template'), help_text=_('Parameter Template') + PartParameterTemplate, + on_delete=models.CASCADE, + related_name='instances', + verbose_name=_('Template'), + help_text=_('Parameter Template'), ) data = models.CharField( max_length=500, - verbose_name=_('Data'), help_text=_('Parameter Value'), - validators=[ - MinLengthValidator(1), - ] + verbose_name=_('Data'), + help_text=_('Parameter Value'), + validators=[MinLengthValidator(1)], ) - data_numeric = models.FloatField( - default=None, - null=True, - blank=True, - ) + data_numeric = models.FloatField(default=None, null=True, blank=True) @property def units(self): @@ -3596,9 +3778,12 @@ class PartCategoryParameterTemplate(MetadataMixin, models.Model): class Meta: """Metaclass providing extra model definition""" + constraints = [ - UniqueConstraint(fields=['category', 'parameter_template'], - name='unique_category_parameter_template_pair') + UniqueConstraint( + fields=['category', 'parameter_template'], + name='unique_category_parameter_template_pair', + ) ] def __str__(self): @@ -3607,22 +3792,28 @@ class PartCategoryParameterTemplate(MetadataMixin, models.Model): return f'{self.category.name} | {self.parameter_template.name} | {self.default_value}' return f'{self.category.name} | {self.parameter_template.name}' - category = models.ForeignKey(PartCategory, - on_delete=models.CASCADE, - related_name='parameter_templates', - verbose_name=_('Category'), - help_text=_('Part Category')) + category = models.ForeignKey( + PartCategory, + on_delete=models.CASCADE, + related_name='parameter_templates', + verbose_name=_('Category'), + help_text=_('Part Category'), + ) - parameter_template = models.ForeignKey(PartParameterTemplate, - on_delete=models.CASCADE, - related_name='part_categories', - verbose_name=_('Parameter Template'), - help_text=_('Parameter Template')) + parameter_template = models.ForeignKey( + PartParameterTemplate, + on_delete=models.CASCADE, + related_name='part_categories', + verbose_name=_('Parameter Template'), + help_text=_('Parameter Template'), + ) - default_value = models.CharField(max_length=500, - blank=True, - verbose_name=_('Default Value'), - help_text=_('Default Parameter Value')) + default_value = models.CharField( + max_length=500, + blank=True, + verbose_name=_('Default Value'), + help_text=_('Default Parameter Value'), + ) class BomItem(DataImportMixin, MetadataMixin, models.Model): @@ -3647,9 +3838,7 @@ class BomItem(DataImportMixin, MetadataMixin, models.Model): # Fields available for bulk import IMPORT_FIELDS = { - 'quantity': { - 'required': True - }, + 'quantity': {'required': True}, 'reference': {}, 'overage': {}, 'allow_variants': {}, @@ -3657,30 +3846,16 @@ class BomItem(DataImportMixin, MetadataMixin, models.Model): 'optional': {}, 'consumable': {}, 'note': {}, - 'part': { - 'label': _('Part'), - 'help_text': _('Part ID or part name'), - }, - 'part_id': { - 'label': _('Part ID'), - 'help_text': _('Unique part ID value') - }, - 'part_name': { - 'label': _('Part Name'), - 'help_text': _('Part name'), - }, - 'part_ipn': { - 'label': _('Part IPN'), - 'help_text': _('Part IPN value'), - }, - 'level': { - 'label': _('Level'), - 'help_text': _('BOM level'), - } + 'part': {'label': _('Part'), 'help_text': _('Part ID or part name')}, + 'part_id': {'label': _('Part ID'), 'help_text': _('Unique part ID value')}, + 'part_name': {'label': _('Part Name'), 'help_text': _('Part name')}, + 'part_ipn': {'label': _('Part IPN'), 'help_text': _('Part IPN value')}, + 'level': {'label': _('Level'), 'help_text': _('BOM level')}, } class Meta: """Metaclass providing extra model definition""" + verbose_name = _("BOM Item") def __str__(self): @@ -3697,13 +3872,13 @@ class BomItem(DataImportMixin, MetadataMixin, models.Model): assemblies = [self.part] if self.inherited: - assemblies += list( - self.part.get_descendants(include_self=False) - ) + assemblies += list(self.part.get_descendants(include_self=False)) return assemblies - def get_valid_parts_for_allocation(self, allow_variants=True, allow_substitutes=True): + def get_valid_parts_for_allocation( + self, allow_variants=True, allow_substitutes=True + ): """Return a list of valid parts which can be allocated against this BomItem. Includes: @@ -3760,56 +3935,82 @@ class BomItem(DataImportMixin, MetadataMixin, models.Model): # A link to the parent part # Each part will get a reverse lookup field 'bom_items' - part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='bom_items', - verbose_name=_('Part'), - help_text=_('Select parent part'), - limit_choices_to={ - 'assembly': True, - }) + part = models.ForeignKey( + Part, + on_delete=models.CASCADE, + related_name='bom_items', + verbose_name=_('Part'), + help_text=_('Select parent part'), + limit_choices_to={'assembly': True}, + ) # A link to the child item (sub-part) # Each part will get a reverse lookup field 'used_in' - sub_part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='used_in', - verbose_name=_('Sub part'), - help_text=_('Select part to be used in BOM'), - limit_choices_to={ - 'component': True, - }) + sub_part = models.ForeignKey( + Part, + on_delete=models.CASCADE, + related_name='used_in', + verbose_name=_('Sub part'), + help_text=_('Select part to be used in BOM'), + limit_choices_to={'component': True}, + ) # Quantity required - quantity = models.DecimalField(default=1.0, max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], verbose_name=_('Quantity'), help_text=_('BOM quantity for this BOM item')) + quantity = models.DecimalField( + default=1.0, + max_digits=15, + decimal_places=5, + validators=[MinValueValidator(0)], + verbose_name=_('Quantity'), + help_text=_('BOM quantity for this BOM item'), + ) optional = models.BooleanField( default=False, verbose_name=_('Optional'), - help_text=_("This BOM item is optional") + help_text=_("This BOM item is optional"), ) consumable = models.BooleanField( default=False, verbose_name=_('Consumable'), - help_text=_("This BOM item is consumable (it is not tracked in build orders)") + help_text=_("This BOM item is consumable (it is not tracked in build orders)"), ) - overage = models.CharField(max_length=24, blank=True, validators=[validators.validate_overage], - verbose_name=_('Overage'), - help_text=_('Estimated build wastage quantity (absolute or percentage)') - ) + overage = models.CharField( + max_length=24, + blank=True, + validators=[validators.validate_overage], + verbose_name=_('Overage'), + help_text=_('Estimated build wastage quantity (absolute or percentage)'), + ) - reference = models.CharField(max_length=5000, blank=True, verbose_name=_('Reference'), help_text=_('BOM item reference')) + reference = models.CharField( + max_length=5000, + blank=True, + verbose_name=_('Reference'), + help_text=_('BOM item reference'), + ) # Note attached to this BOM line item - note = models.CharField(max_length=500, blank=True, verbose_name=_('Note'), help_text=_('BOM item notes')) + note = models.CharField( + max_length=500, + blank=True, + verbose_name=_('Note'), + help_text=_('BOM item notes'), + ) checksum = models.CharField( - max_length=128, blank=True, - verbose_name=_('Checksum'), help_text=_('BOM line checksum') + max_length=128, + blank=True, + verbose_name=_('Checksum'), + help_text=_('BOM line checksum'), ) validated = models.BooleanField( default=False, verbose_name=_('Validated'), - help_text=_('This BOM item has been validated') + help_text=_('This BOM item has been validated'), ) inherited = models.BooleanField( @@ -3821,7 +4022,7 @@ class BomItem(DataImportMixin, MetadataMixin, models.Model): allow_variants = models.BooleanField( default=False, verbose_name=_('Allow Variants'), - help_text=_('Stock items for variant parts can be used for this BOM item') + help_text=_('Stock items for variant parts can be used for this BOM item'), ) def get_item_hash(self): @@ -3849,7 +4050,7 @@ class BomItem(DataImportMixin, MetadataMixin, models.Model): self.optional, self.inherited, self.consumable, - self.allow_variants + self.allow_variants, ] for component in components: @@ -3894,9 +4095,7 @@ class BomItem(DataImportMixin, MetadataMixin, models.Model): try: self.quantity = Decimal(self.quantity) except InvalidOperation: - raise ValidationError({ - 'quantity': _('Must be a valid number') - }) + raise ValidationError({'quantity': _('Must be a valid number')}) try: # Check for circular BOM references @@ -3907,7 +4106,9 @@ class BomItem(DataImportMixin, MetadataMixin, models.Model): if self.sub_part.trackable: if self.quantity != int(self.quantity): raise ValidationError({ - "quantity": _("Quantity must be integer value for trackable parts") + "quantity": _( + "Quantity must be integer value for trackable parts" + ) }) # Force the upstream part to be trackable if the sub_part is trackable @@ -3984,8 +4185,12 @@ class BomItem(DataImportMixin, MetadataMixin, models.Model): def price_range(self, internal=False): """Return the price-range for this BOM item.""" # get internal price setting - use_internal = common.models.InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False, cache=False) - prange = self.sub_part.get_price_range(self.quantity, internal=use_internal and internal) + use_internal = common.models.InvenTreeSetting.get_setting( + 'PART_BOM_USE_INTERNAL_PRICE', False, cache=False + ) + prange = self.sub_part.get_price_range( + self.quantity, internal=use_internal and internal + ) if prange is None: return prange @@ -4007,15 +4212,19 @@ def update_bom_build_lines(sender, instance, created, **kwargs): """Update existing build orders when a BomItem is created or edited""" if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData(): import build.tasks - InvenTree.tasks.offload_task( - build.tasks.update_build_order_lines, - instance.pk - ) + + InvenTree.tasks.offload_task(build.tasks.update_build_order_lines, instance.pk) @receiver(post_save, sender=BomItem, dispatch_uid='post_save_bom_item') -@receiver(post_save, sender=PartSellPriceBreak, dispatch_uid='post_save_sale_price_break') -@receiver(post_save, sender=PartInternalPriceBreak, dispatch_uid='post_save_internal_price_break') +@receiver( + post_save, sender=PartSellPriceBreak, dispatch_uid='post_save_sale_price_break' +) +@receiver( + post_save, + sender=PartInternalPriceBreak, + dispatch_uid='post_save_internal_price_break', +) def update_pricing_after_edit(sender, instance, created, **kwargs): """Callback function when a part price break is created or updated""" # Update part pricing *unless* we are importing data @@ -4024,8 +4233,14 @@ def update_pricing_after_edit(sender, instance, created, **kwargs): @receiver(post_delete, sender=BomItem, dispatch_uid='post_delete_bom_item') -@receiver(post_delete, sender=PartSellPriceBreak, dispatch_uid='post_delete_sale_price_break') -@receiver(post_delete, sender=PartInternalPriceBreak, dispatch_uid='post_delete_internal_price_break') +@receiver( + post_delete, sender=PartSellPriceBreak, dispatch_uid='post_delete_sale_price_break' +) +@receiver( + post_delete, + sender=PartInternalPriceBreak, + dispatch_uid='post_delete_internal_price_break', +) def update_pricing_after_delete(sender, instance, **kwargs): """Callback function when a part price break is deleted""" # Update part pricing *unless* we are importing data @@ -4043,6 +4258,7 @@ class BomItemSubstitute(MetadataMixin, models.Model): class Meta: """Metaclass providing extra model definition""" + verbose_name = _("BOM Item Substitute") # Prevent duplication of substitute parts @@ -4064,7 +4280,7 @@ class BomItemSubstitute(MetadataMixin, models.Model): if self.part == self.bom_item.sub_part: raise ValidationError({ - "part": _("Substitute part cannot be the same as the master part"), + "part": _("Substitute part cannot be the same as the master part") }) @staticmethod @@ -4086,9 +4302,7 @@ class BomItemSubstitute(MetadataMixin, models.Model): related_name='substitute_items', verbose_name=_('Part'), help_text=_('Substitute part'), - limit_choices_to={ - 'component': True, - } + limit_choices_to={'component': True}, ) @@ -4097,14 +4311,23 @@ class PartRelated(MetadataMixin, models.Model): class Meta: """Metaclass defines extra model properties""" + unique_together = ('part_1', 'part_2') - part_1 = models.ForeignKey(Part, related_name='related_parts_1', - verbose_name=_('Part 1'), on_delete=models.CASCADE) + part_1 = models.ForeignKey( + Part, + related_name='related_parts_1', + verbose_name=_('Part 1'), + on_delete=models.CASCADE, + ) - part_2 = models.ForeignKey(Part, related_name='related_parts_2', - on_delete=models.CASCADE, - verbose_name=_('Part 2'), help_text=_('Select Related Part')) + part_2 = models.ForeignKey( + Part, + related_name='related_parts_2', + on_delete=models.CASCADE, + verbose_name=_('Part 2'), + help_text=_('Select Related Part'), + ) def __str__(self): """Return a string representation of this Part-Part relationship""" @@ -4121,7 +4344,9 @@ class PartRelated(MetadataMixin, models.Model): super().clean() if self.part_1 == self.part_2: - raise ValidationError(_("Part relationship cannot be created between a part and itself")) + raise ValidationError( + _("Part relationship cannot be created between a part and itself") + ) # Check for inverse relationship if PartRelated.objects.filter(part_1=self.part_2, part_2=self.part_1).exists(): diff --git a/InvenTree/part/part.py b/InvenTree/part/part.py index fc85218092..bcee84167c 100644 --- a/InvenTree/part/part.py +++ b/InvenTree/part/part.py @@ -25,10 +25,7 @@ def MakePartTemplate(fmt): # This will then export just the row headers! query = Part.objects.filter(pk=None) - dataset = PartImportResource().export( - queryset=query, - importing=True - ) + dataset = PartImportResource().export(queryset=query, importing=True) data = dataset.export(fmt) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index db347f347a..5a17740642 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -36,12 +36,24 @@ import users.models from InvenTree.status_codes import BuildStatusGroups from InvenTree.tasks import offload_task -from .models import (BomItem, BomItemSubstitute, Part, PartAttachment, - PartCategory, PartCategoryParameterTemplate, - PartInternalPriceBreak, PartParameter, - PartParameterTemplate, PartPricing, PartRelated, - PartSellPriceBreak, PartStar, PartStocktake, - PartStocktakeReport, PartTestTemplate) +from .models import ( + BomItem, + BomItemSubstitute, + Part, + PartAttachment, + PartCategory, + PartCategoryParameterTemplate, + PartInternalPriceBreak, + PartParameter, + PartParameterTemplate, + PartPricing, + PartRelated, + PartSellPriceBreak, + PartStar, + PartStocktake, + PartStocktakeReport, + PartTestTemplate, +) logger = logging.getLogger("inventree") @@ -51,6 +63,7 @@ class CategorySerializer(InvenTree.serializers.InvenTreeModelSerializer): class Meta: """Metaclass defining serializer fields""" + model = PartCategory fields = [ 'pk', @@ -86,9 +99,7 @@ class CategorySerializer(InvenTree.serializers.InvenTreeModelSerializer): def annotate_queryset(queryset): """Annotate extra information to the queryset""" # Annotate the number of 'parts' which exist in each category (including subcategories!) - queryset = queryset.annotate( - part_count=part.filters.annotate_category_parts() - ) + queryset = queryset.annotate(part_count=part.filters.annotate_category_parts()) return queryset @@ -101,9 +112,7 @@ class CategorySerializer(InvenTree.serializers.InvenTreeModelSerializer): starred = serializers.SerializerMethodField() path = serializers.ListField( - child=serializers.DictField(), - source='get_path', - read_only=True, + child=serializers.DictField(), source='get_path', read_only=True ) @@ -112,14 +121,9 @@ class CategoryTree(InvenTree.serializers.InvenTreeModelSerializer): class Meta: """Metaclass defining serializer fields""" + model = PartCategory - fields = [ - 'pk', - 'name', - 'parent', - 'icon', - 'structural', - ] + fields = ['pk', 'name', 'parent', 'icon', 'structural'] class PartAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer): @@ -127,10 +131,11 @@ class PartAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializ class Meta: """Metaclass defining serializer fields""" + model = PartAttachment fields = InvenTree.serializers.InvenTreeAttachmentSerializer.attachment_fields([ - 'part', + 'part' ]) @@ -139,6 +144,7 @@ class PartTestTemplateSerializer(InvenTree.serializers.InvenTreeModelSerializer) class Meta: """Metaclass defining serializer fields""" + model = PartTestTemplate fields = [ @@ -160,20 +166,17 @@ class PartSalePriceSerializer(InvenTree.serializers.InvenTreeModelSerializer): class Meta: """Metaclass defining serializer fields""" + model = PartSellPriceBreak - fields = [ - 'pk', - 'part', - 'quantity', - 'price', - 'price_currency', - ] + fields = ['pk', 'part', 'quantity', 'price', 'price_currency'] quantity = InvenTree.serializers.InvenTreeDecimalField() price = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True) - price_currency = InvenTree.serializers.InvenTreeCurrencySerializer(help_text=_('Purchase currency of this stock item')) + price_currency = InvenTree.serializers.InvenTreeCurrencySerializer( + help_text=_('Purchase currency of this stock item') + ) class PartInternalPriceSerializer(InvenTree.serializers.InvenTreeModelSerializer): @@ -181,22 +184,17 @@ class PartInternalPriceSerializer(InvenTree.serializers.InvenTreeModelSerializer class Meta: """Metaclass defining serializer fields""" + model = PartInternalPriceBreak - fields = [ - 'pk', - 'part', - 'quantity', - 'price', - 'price_currency', - ] + fields = ['pk', 'part', 'quantity', 'price', 'price_currency'] quantity = InvenTree.serializers.InvenTreeDecimalField() - price = InvenTree.serializers.InvenTreeMoneySerializer( - allow_null=True - ) + price = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True) - price_currency = InvenTree.serializers.InvenTreeCurrencySerializer(help_text=_('Purchase currency of this stock item')) + price_currency = InvenTree.serializers.InvenTreeCurrencySerializer( + help_text=_('Purchase currency of this stock item') + ) class PartThumbSerializer(serializers.Serializer): @@ -214,10 +212,9 @@ class PartThumbSerializerUpdate(InvenTree.serializers.InvenTreeModelSerializer): class Meta: """Metaclass defining serializer fields""" + model = Part - fields = [ - 'image', - ] + fields = ['image'] def validate_image(self, value): """Check that file is an image.""" @@ -234,15 +231,9 @@ class PartParameterTemplateSerializer(InvenTree.serializers.InvenTreeModelSerial class Meta: """Metaclass defining serializer fields""" + model = PartParameterTemplate - fields = [ - 'pk', - 'name', - 'units', - 'description', - 'checkbox', - 'choices', - ] + fields = ['pk', 'name', 'units', 'description', 'checkbox', 'choices'] class PartBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer): @@ -250,6 +241,7 @@ class PartBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer): class Meta: """Metaclass defining serializer fields""" + model = Part fields = [ 'pk', @@ -273,9 +265,7 @@ class PartBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer): 'pricing_max', ] - read_only_fields = [ - 'barcode_hash', - ] + read_only_fields = ['barcode_hash'] def __init__(self, *args, **kwargs): """Custom initialization routine for the PartBrief serializer""" @@ -290,8 +280,12 @@ class PartBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer): thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True) # Pricing fields - pricing_min = InvenTree.serializers.InvenTreeMoneySerializer(source='pricing_data.overall_min', allow_null=True, read_only=True) - pricing_max = InvenTree.serializers.InvenTreeMoneySerializer(source='pricing_data.overall_max', allow_null=True, read_only=True) + pricing_min = InvenTree.serializers.InvenTreeMoneySerializer( + source='pricing_data.overall_min', allow_null=True, read_only=True + ) + pricing_max = InvenTree.serializers.InvenTreeMoneySerializer( + source='pricing_data.overall_max', allow_null=True, read_only=True + ) class PartParameterSerializer(InvenTree.serializers.InvenTreeModelSerializer): @@ -299,6 +293,7 @@ class PartParameterSerializer(InvenTree.serializers.InvenTreeModelSerializer): class Meta: """Metaclass defining serializer fields""" + model = PartParameter fields = [ 'pk', @@ -327,7 +322,9 @@ class PartParameterSerializer(InvenTree.serializers.InvenTreeModelSerializer): self.fields.pop('template_detail') part_detail = PartBriefSerializer(source='part', many=False, read_only=True) - template_detail = PartParameterTemplateSerializer(source='template', many=False, read_only=True) + template_detail = PartParameterTemplateSerializer( + source='template', many=False, read_only=True + ) class PartSetCategorySerializer(serializers.Serializer): @@ -335,14 +332,14 @@ class PartSetCategorySerializer(serializers.Serializer): class Meta: """Metaclass options""" - fields = [ - 'parts', - 'category', - ] + + fields = ['parts', 'category'] parts = serializers.PrimaryKeyRelatedField( queryset=Part.objects.all(), - many=True, required=True, allow_null=False, + many=True, + required=True, + allow_null=False, label=_('Parts'), ) @@ -355,9 +352,11 @@ class PartSetCategorySerializer(serializers.Serializer): category = serializers.PrimaryKeyRelatedField( queryset=PartCategory.objects.filter(structural=False), - many=False, required=True, allow_null=False, + many=False, + required=True, + allow_null=False, label=_('Category'), - help_text=_('Select category',) + help_text=_('Select category'), ) @transaction.atomic @@ -387,28 +386,37 @@ class DuplicatePartSerializer(serializers.Serializer): part = serializers.PrimaryKeyRelatedField( queryset=Part.objects.all(), - label=_('Original Part'), help_text=_('Select original part to duplicate'), + label=_('Original Part'), + help_text=_('Select original part to duplicate'), required=True, ) copy_image = serializers.BooleanField( - label=_('Copy Image'), help_text=_('Copy image from original part'), - required=False, default=False, + label=_('Copy Image'), + help_text=_('Copy image from original part'), + required=False, + default=False, ) copy_bom = serializers.BooleanField( - label=_('Copy BOM'), help_text=_('Copy bill of materials from original part'), - required=False, default=False, + label=_('Copy BOM'), + help_text=_('Copy bill of materials from original part'), + required=False, + default=False, ) copy_parameters = serializers.BooleanField( - label=_('Copy Parameters'), help_text=_('Copy parameter data from original part'), - required=False, default=False, + label=_('Copy Parameters'), + help_text=_('Copy parameter data from original part'), + required=False, + default=False, ) copy_notes = serializers.BooleanField( - label=_('Copy Notes'), help_text=_('Copy notes from original part'), - required=False, default=True, + label=_('Copy Notes'), + help_text=_('Copy notes from original part'), + required=False, + default=True, ) @@ -416,15 +424,22 @@ class InitialStockSerializer(serializers.Serializer): """Serializer for creating initial stock quantity.""" quantity = serializers.DecimalField( - max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], - label=_('Initial Stock Quantity'), help_text=_('Specify initial stock quantity for this Part. If quantity is zero, no stock is added.'), + max_digits=15, + decimal_places=5, + validators=[MinValueValidator(0)], + label=_('Initial Stock Quantity'), + help_text=_( + 'Specify initial stock quantity for this Part. If quantity is zero, no stock is added.' + ), required=True, ) location = serializers.PrimaryKeyRelatedField( queryset=stock.models.StockLocation.objects.all(), - label=_('Initial Stock Location'), help_text=_('Specify initial stock location for this Part'), - allow_null=True, required=False, + label=_('Initial Stock Location'), + help_text=_('Specify initial stock location for this Part'), + allow_null=True, + required=False, ) @@ -433,53 +448,65 @@ class InitialSupplierSerializer(serializers.Serializer): supplier = serializers.PrimaryKeyRelatedField( queryset=company.models.Company.objects.all(), - label=_('Supplier'), help_text=_('Select supplier (or leave blank to skip)'), - allow_null=True, required=False, + label=_('Supplier'), + help_text=_('Select supplier (or leave blank to skip)'), + allow_null=True, + required=False, ) sku = serializers.CharField( - max_length=100, required=False, allow_blank=True, - label=_('SKU'), help_text=_('Supplier stock keeping unit'), + max_length=100, + required=False, + allow_blank=True, + label=_('SKU'), + help_text=_('Supplier stock keeping unit'), ) manufacturer = serializers.PrimaryKeyRelatedField( queryset=company.models.Company.objects.all(), - label=_('Manufacturer'), help_text=_('Select manufacturer (or leave blank to skip)'), - allow_null=True, required=False, + label=_('Manufacturer'), + help_text=_('Select manufacturer (or leave blank to skip)'), + allow_null=True, + required=False, ) mpn = serializers.CharField( - max_length=100, required=False, allow_blank=True, - label=_('MPN'), help_text=_('Manufacturer part number'), + max_length=100, + required=False, + allow_blank=True, + label=_('MPN'), + help_text=_('Manufacturer part number'), ) def validate_supplier(self, company): """Validation for the provided Supplier""" if company and not company.is_supplier: - raise serializers.ValidationError(_('Selected company is not a valid supplier')) + raise serializers.ValidationError( + _('Selected company is not a valid supplier') + ) return company def validate_manufacturer(self, company): """Validation for the provided Manufacturer""" if company and not company.is_manufacturer: - raise serializers.ValidationError(_('Selected company is not a valid manufacturer')) + raise serializers.ValidationError( + _('Selected company is not a valid manufacturer') + ) return company def validate(self, data): """Extra validation for this serializer""" if company.models.ManufacturerPart.objects.filter( - manufacturer=data.get('manufacturer', None), - MPN=data.get('mpn', '') + manufacturer=data.get('manufacturer', None), MPN=data.get('mpn', '') ).exists(): raise serializers.ValidationError({ 'mpn': _('Manufacturer part matching this MPN already exists') }) if company.models.SupplierPart.objects.filter( - supplier=data.get('supplier', None), - SKU=data.get('sku', '') + supplier=data.get('supplier', None), SKU=data.get('sku', '') ).exists(): raise serializers.ValidationError({ 'sku': _('Supplier part matching this SKU already exists') @@ -488,7 +515,10 @@ class InitialSupplierSerializer(serializers.Serializer): return data -class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serializers.InvenTreeTagModelSerializer): +class PartSerializer( + InvenTree.serializers.RemoteImageMixin, + InvenTree.serializers.InvenTreeTagModelSerializer, +): """Serializer for complete detail information of a part. Used when displaying all details of a single component. @@ -496,6 +526,7 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize class Meta: """Metaclass defining serializer fields""" + model = Part partial = True fields = [ @@ -537,7 +568,6 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize 'pricing_min', 'pricing_max', 'responsible', - # Annotated fields 'allocated_to_build_orders', 'allocated_to_sales_orders', @@ -550,20 +580,15 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize 'total_in_stock', 'unallocated_stock', 'variant_stock', - # Fields only used for Part creation 'duplicate', 'initial_stock', 'initial_supplier', 'copy_category_parameters', - 'tags', ] - read_only_fields = [ - 'barcode_hash', - 'creation_date', - ] + read_only_fields = ['barcode_hash', 'creation_date'] tags = TagListSerializerField(required=False) @@ -624,21 +649,19 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize Performing database queries as efficiently as possible, to reduce database trips. """ # Annotate with the total number of stock items - queryset = queryset.annotate( - stock_item_count=SubqueryCount('stock_items') - ) + queryset = queryset.annotate(stock_item_count=SubqueryCount('stock_items')) # Annotate with the total variant stock quantity variant_query = part.filters.variant_stock_query() queryset = queryset.annotate( - variant_stock=part.filters.annotate_variant_quantity(variant_query, reference='quantity'), + variant_stock=part.filters.annotate_variant_quantity( + variant_query, reference='quantity' + ) ) # Filter to limit builds to "active" - build_filter = Q( - status__in=BuildStatusGroups.ACTIVE_CODES - ) + build_filter = Q(status__in=BuildStatusGroups.ACTIVE_CODES) # Annotate with the total 'building' quantity queryset = queryset.annotate( @@ -655,7 +678,7 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize SubqueryCount('supplier_parts'), Decimal(0), output_field=models.DecimalField(), - ), + ) ) # TODO: This could do with some refactoring @@ -672,8 +695,7 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize # This is the 'in_stock' quantity summed with the 'variant_stock' quantity queryset = queryset.annotate( total_in_stock=ExpressionWrapper( - F('in_stock') + F('variant_stock'), - output_field=models.DecimalField(), + F('in_stock') + F('variant_stock'), output_field=models.DecimalField() ) ) @@ -681,14 +703,16 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize # This is the current stock, minus any allocations queryset = queryset.annotate( unallocated_stock=ExpressionWrapper( - F('total_in_stock') - F('allocated_to_sales_orders') - F('allocated_to_build_orders'), + F('total_in_stock') + - F('allocated_to_sales_orders') + - F('allocated_to_build_orders'), output_field=models.DecimalField(), ) ) # Annotate with the total 'required for builds' quantity queryset = queryset.annotate( - required_for_build_orders=part.filters.annotate_build_order_requirements(), + required_for_build_orders=part.filters.annotate_build_order_requirements() ) return queryset @@ -701,14 +725,13 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize category_detail = CategorySerializer(source='category', many=False, read_only=True) category_path = serializers.ListField( - child=serializers.DictField(), - source='category.get_path', - read_only=True, + child=serializers.DictField(), source='category.get_path', read_only=True ) responsible = serializers.PrimaryKeyRelatedField( queryset=users.models.Owner.objects.all(), - required=False, allow_null=True, + required=False, + allow_null=True, source='responsible_owner', ) @@ -727,7 +750,9 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize minimum_stock = serializers.FloatField() - image = InvenTree.serializers.InvenTreeImageSerializerField(required=False, allow_null=True) + image = InvenTree.serializers.InvenTreeImageSerializerField( + required=False, allow_null=True + ) thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True) starred = serializers.SerializerMethodField() @@ -735,32 +760,40 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize category = serializers.PrimaryKeyRelatedField(queryset=PartCategory.objects.all()) # Pricing fields - pricing_min = InvenTree.serializers.InvenTreeMoneySerializer(source='pricing_data.overall_min', allow_null=True, read_only=True) - pricing_max = InvenTree.serializers.InvenTreeMoneySerializer(source='pricing_data.overall_max', allow_null=True, read_only=True) - - parameters = PartParameterSerializer( - many=True, - read_only=True, + pricing_min = InvenTree.serializers.InvenTreeMoneySerializer( + source='pricing_data.overall_min', allow_null=True, read_only=True ) + pricing_max = InvenTree.serializers.InvenTreeMoneySerializer( + source='pricing_data.overall_max', allow_null=True, read_only=True + ) + + parameters = PartParameterSerializer(many=True, read_only=True) # Extra fields used only for creation of a new Part instance duplicate = DuplicatePartSerializer( - label=_('Duplicate Part'), help_text=_('Copy initial data from another Part'), - write_only=True, required=False + label=_('Duplicate Part'), + help_text=_('Copy initial data from another Part'), + write_only=True, + required=False, ) initial_stock = InitialStockSerializer( - label=_('Initial Stock'), help_text=_('Create Part with initial stock quantity'), - write_only=True, required=False, + label=_('Initial Stock'), + help_text=_('Create Part with initial stock quantity'), + write_only=True, + required=False, ) initial_supplier = InitialSupplierSerializer( - label=_('Supplier Information'), help_text=_('Add initial supplier information for this part'), - write_only=True, required=False, + label=_('Supplier Information'), + help_text=_('Add initial supplier information for this part'), + write_only=True, + required=False, ) copy_category_parameters = serializers.BooleanField( - default=True, required=False, + default=True, + required=False, label=_('Copy Category Parameters'), help_text=_('Copy parameter templates from selected part category'), ) @@ -782,10 +815,7 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize img = img.split(os.path.sep)[-1] # Ensure that the file actually exists - img_path = os.path.join( - part_helpers.get_part_image_directory(), - img - ) + img_path = os.path.join(part_helpers.get_part_image_directory(), img) if not os.path.exists(img_path) or not os.path.isfile(img_path): raise ValidationError(_('Image file does not exist')) @@ -838,8 +868,7 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize for template in templates: # First ensure that the part doesn't have that parameter if PartParameter.objects.filter( - part=instance, - template=template.parameter_template + part=instance, template=template.parameter_template ).exists(): continue @@ -848,10 +877,12 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize part=instance, template=template.parameter_template, data=template.default_value, - save=True + save=True, ) except IntegrityError: - logger.exception("Could not create new PartParameter for part %s", instance) + logger.exception( + "Could not create new PartParameter for part %s", instance + ) # Create initial stock entry if initial_stock: @@ -860,24 +891,19 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize if quantity > 0: stockitem = stock.models.StockItem( - part=instance, - quantity=quantity, - location=location, + part=instance, quantity=quantity, location=location ) stockitem.save(user=self.context['request'].user) # Create initial supplier information if initial_supplier: - manufacturer = initial_supplier.get('manufacturer', None) mpn = initial_supplier.get('mpn', '') if manufacturer and mpn: manu_part = company.models.ManufacturerPart.objects.create( - part=instance, - manufacturer=manufacturer, - MPN=mpn + part=instance, manufacturer=manufacturer, MPN=mpn ) else: manu_part = None @@ -905,10 +931,7 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize existing_image = data.pop('existing_image', None) if existing_image: - img_path = os.path.join( - part_helpers.PART_IMAGE_DIR, - existing_image - ) + img_path = os.path.join(part_helpers.PART_IMAGE_DIR, existing_image) part.image = img_path part.save() @@ -924,10 +947,7 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize # Construct a simplified name for the image filename = f"part_{part.pk}_image.{fmt.lower()}" - part.image.save( - filename, - ContentFile(buffer.getvalue()), - ) + part.image.save(filename, ContentFile(buffer.getvalue())) return self.instance @@ -954,14 +974,13 @@ class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer): 'user_detail', ] - read_only_fields = [ - 'date', - 'user', - ] + read_only_fields = ['date', 'user'] quantity = serializers.FloatField() - user_detail = InvenTree.serializers.UserSerializer(source='user', read_only=True, many=False) + user_detail = InvenTree.serializers.UserSerializer( + source='user', read_only=True, many=False + ) cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True) cost_min_currency = InvenTree.serializers.InvenTreeCurrencySerializer() @@ -987,16 +1006,11 @@ class PartStocktakeReportSerializer(InvenTree.serializers.InvenTreeModelSerializ """Metaclass defines serializer fields""" model = PartStocktakeReport - fields = [ - 'pk', - 'date', - 'report', - 'part_count', - 'user', - 'user_detail', - ] + fields = ['pk', 'date', 'report', 'part_count', 'user', 'user_detail'] - user_detail = InvenTree.serializers.UserSerializer(source='user', read_only=True, many=False) + user_detail = InvenTree.serializers.UserSerializer( + source='user', read_only=True, many=False + ) report = InvenTree.serializers.InvenTreeAttachmentSerializerField(read_only=True) @@ -1006,26 +1020,38 @@ class PartStocktakeReportGenerateSerializer(serializers.Serializer): part = serializers.PrimaryKeyRelatedField( queryset=Part.objects.all(), - required=False, allow_null=True, - label=_('Part'), help_text=_('Limit stocktake report to a particular part, and any variant parts') + required=False, + allow_null=True, + label=_('Part'), + help_text=_( + 'Limit stocktake report to a particular part, and any variant parts' + ), ) category = serializers.PrimaryKeyRelatedField( queryset=PartCategory.objects.all(), - required=False, allow_null=True, - label=_('Category'), help_text=_('Limit stocktake report to a particular part category, and any child categories'), + required=False, + allow_null=True, + label=_('Category'), + help_text=_( + 'Limit stocktake report to a particular part category, and any child categories' + ), ) location = serializers.PrimaryKeyRelatedField( queryset=stock.models.StockLocation.objects.all(), - required=False, allow_null=True, - label=_('Location'), help_text=_('Limit stocktake report to a particular stock location, and any child locations') + required=False, + allow_null=True, + label=_('Location'), + help_text=_( + 'Limit stocktake report to a particular stock location, and any child locations' + ), ) exclude_external = serializers.BooleanField( default=True, label=_('Exclude External Stock'), - help_text=_('Exclude stock items in external locations') + help_text=_('Exclude stock items in external locations'), ) generate_report = serializers.BooleanField( @@ -1037,14 +1063,16 @@ class PartStocktakeReportGenerateSerializer(serializers.Serializer): update_parts = serializers.BooleanField( default=True, label=_('Update Parts'), - help_text=_('Update specified parts with calculated stocktake data') + help_text=_('Update specified parts with calculated stocktake data'), ) def validate(self, data): """Custom validation for this serializer""" # Stocktake functionality must be enabled if not common.models.InvenTreeSetting.get_setting('STOCKTAKE_ENABLE', False): - raise serializers.ValidationError(_("Stocktake functionality is not enabled")) + raise serializers.ValidationError( + _("Stocktake functionality is not enabled") + ) # Check that background worker is running if not InvenTree.status.is_worker_running(): @@ -1076,6 +1104,7 @@ class PartPricingSerializer(InvenTree.serializers.InvenTreeModelSerializer): class Meta: """Metaclass defining serializer fields""" + model = PartPricing fields = [ 'currency', @@ -1111,59 +1140,99 @@ class PartPricingSerializer(InvenTree.serializers.InvenTreeModelSerializer): scheduled_for_update = serializers.BooleanField(read_only=True) # Custom serializers - bom_cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) - bom_cost_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) + bom_cost_min = InvenTree.serializers.InvenTreeMoneySerializer( + allow_null=True, read_only=True + ) + bom_cost_max = InvenTree.serializers.InvenTreeMoneySerializer( + allow_null=True, read_only=True + ) - purchase_cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) - purchase_cost_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) + purchase_cost_min = InvenTree.serializers.InvenTreeMoneySerializer( + allow_null=True, read_only=True + ) + purchase_cost_max = InvenTree.serializers.InvenTreeMoneySerializer( + allow_null=True, read_only=True + ) - internal_cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) - internal_cost_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) + internal_cost_min = InvenTree.serializers.InvenTreeMoneySerializer( + allow_null=True, read_only=True + ) + internal_cost_max = InvenTree.serializers.InvenTreeMoneySerializer( + allow_null=True, read_only=True + ) - supplier_price_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) - supplier_price_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) + supplier_price_min = InvenTree.serializers.InvenTreeMoneySerializer( + allow_null=True, read_only=True + ) + supplier_price_max = InvenTree.serializers.InvenTreeMoneySerializer( + allow_null=True, read_only=True + ) - variant_cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) - variant_cost_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) + variant_cost_min = InvenTree.serializers.InvenTreeMoneySerializer( + allow_null=True, read_only=True + ) + variant_cost_max = InvenTree.serializers.InvenTreeMoneySerializer( + allow_null=True, read_only=True + ) override_min = InvenTree.serializers.InvenTreeMoneySerializer( label=_('Minimum Price'), help_text=_('Override calculated value for minimum price'), - allow_null=True, read_only=False, required=False, + allow_null=True, + read_only=False, + required=False, ) override_min_currency = serializers.ChoiceField( label=_('Minimum price currency'), - read_only=False, required=False, + read_only=False, + required=False, choices=common.settings.currency_code_mappings(), ) override_max = InvenTree.serializers.InvenTreeMoneySerializer( label=_('Maximum Price'), help_text=_('Override calculated value for maximum price'), - allow_null=True, read_only=False, required=False, + allow_null=True, + read_only=False, + required=False, ) override_max_currency = serializers.ChoiceField( label=_('Maximum price currency'), - read_only=False, required=False, + read_only=False, + required=False, choices=common.settings.currency_code_mappings(), ) - overall_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) - overall_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) + overall_min = InvenTree.serializers.InvenTreeMoneySerializer( + allow_null=True, read_only=True + ) + overall_max = InvenTree.serializers.InvenTreeMoneySerializer( + allow_null=True, read_only=True + ) - sale_price_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) - sale_price_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) + sale_price_min = InvenTree.serializers.InvenTreeMoneySerializer( + allow_null=True, read_only=True + ) + sale_price_max = InvenTree.serializers.InvenTreeMoneySerializer( + allow_null=True, read_only=True + ) - sale_history_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) - sale_history_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) + sale_history_min = InvenTree.serializers.InvenTreeMoneySerializer( + allow_null=True, read_only=True + ) + sale_history_max = InvenTree.serializers.InvenTreeMoneySerializer( + allow_null=True, read_only=True + ) update = serializers.BooleanField( write_only=True, label=_('Update'), help_text=_('Update pricing for this part'), - default=False, required=False, allow_null=True, + default=False, + required=False, + allow_null=True, ) def validate(self, data): @@ -1178,17 +1247,24 @@ class PartPricingSerializer(InvenTree.serializers.InvenTreeModelSerializer): default_currency = common.settings.currency_code_default() if override_min is not None and override_max is not None: - try: override_min = convert_money(override_min, default_currency) override_max = convert_money(override_max, default_currency) except MissingRate: - raise ValidationError(_(f'Could not convert from provided currencies to {default_currency}')) + raise ValidationError( + _( + f'Could not convert from provided currencies to {default_currency}' + ) + ) if override_min > override_max: raise ValidationError({ - 'override_min': _('Minimum price must not be greater than maximum price'), - 'override_max': _('Maximum price must not be less than minimum price') + 'override_min': _( + 'Minimum price must not be greater than maximum price' + ), + 'override_max': _( + 'Maximum price must not be less than minimum price' + ), }) return data @@ -1208,14 +1284,9 @@ class PartRelationSerializer(InvenTree.serializers.InvenTreeModelSerializer): class Meta: """Metaclass defining serializer fields""" + model = PartRelated - fields = [ - 'pk', - 'part_1', - 'part_1_detail', - 'part_2', - 'part_2_detail', - ] + fields = ['pk', 'part_1', 'part_1_detail', 'part_2', 'part_2_detail'] part_1_detail = PartSerializer(source='part_1', read_only=True, many=False) part_2_detail = PartSerializer(source='part_2', read_only=True, many=False) @@ -1226,14 +1297,9 @@ class PartStarSerializer(InvenTree.serializers.InvenTreeModelSerializer): class Meta: """Metaclass defining serializer fields""" + model = PartStar - fields = [ - 'pk', - 'part', - 'partname', - 'user', - 'username', - ] + fields = ['pk', 'part', 'partname', 'user', 'username'] partname = serializers.CharField(source='part.full_name', read_only=True) username = serializers.CharField(source='user.username', read_only=True) @@ -1244,15 +1310,13 @@ class BomItemSubstituteSerializer(InvenTree.serializers.InvenTreeModelSerializer class Meta: """Metaclass defining serializer fields""" - model = BomItemSubstitute - fields = [ - 'pk', - 'bom_item', - 'part', - 'part_detail', - ] - part_detail = PartBriefSerializer(source='part', read_only=True, many=False, pricing=False) + model = BomItemSubstitute + fields = ['pk', 'bom_item', 'part', 'part_detail'] + + part_detail = PartBriefSerializer( + source='part', read_only=True, many=False, pricing=False + ) class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer): @@ -1260,6 +1324,7 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer): class Meta: """Metaclass defining serializer fields""" + model = BomItem fields = [ 'allow_variants', @@ -1279,15 +1344,12 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer): 'sub_part_detail', 'substitutes', 'validated', - # Annotated fields describing available quantity 'available_stock', 'available_substitute_stock', 'available_variant_stock', - # Annotated field describing quantity on order 'on_order', - # Annotated field describing quantity being built 'building', ] @@ -1323,13 +1385,17 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer): return quantity - part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(assembly=True)) + part = serializers.PrimaryKeyRelatedField( + queryset=Part.objects.filter(assembly=True) + ) substitutes = BomItemSubstituteSerializer(many=True, read_only=True) part_detail = PartBriefSerializer(source='part', many=False, read_only=True) - sub_part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(component=True)) + sub_part = serializers.PrimaryKeyRelatedField( + queryset=Part.objects.filter(component=True) + ) sub_part_detail = PartBriefSerializer(source='sub_part', many=False, read_only=True) @@ -1337,8 +1403,12 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer): building = serializers.FloatField(read_only=True) # Cached pricing fields - pricing_min = InvenTree.serializers.InvenTreeMoneySerializer(source='sub_part.pricing.overall_min', allow_null=True, read_only=True) - pricing_max = InvenTree.serializers.InvenTreeMoneySerializer(source='sub_part.pricing.overall_max', allow_null=True, read_only=True) + pricing_min = InvenTree.serializers.InvenTreeMoneySerializer( + source='sub_part.pricing.overall_min', allow_null=True, read_only=True + ) + pricing_max = InvenTree.serializers.InvenTreeMoneySerializer( + source='sub_part.pricing.overall_max', allow_null=True, read_only=True + ) # Annotated fields for available stock available_stock = serializers.FloatField(read_only=True) @@ -1362,13 +1432,10 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer): ) queryset = queryset.prefetch_related( - 'substitutes', - 'substitutes__part__stock_items', + 'substitutes', 'substitutes__part__stock_items' ) - queryset = queryset.prefetch_related( - 'sub_part__builds', - ) + queryset = queryset.prefetch_related('sub_part__builds') return queryset @@ -1388,7 +1455,7 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer): # Annotate with the total "on order" amount for the sub-part queryset = queryset.annotate( - on_order=part.filters.annotate_on_order_quantity(ref), + on_order=part.filters.annotate_on_order_quantity(ref) ) # Annotate with the total "building" amount for the sub-part @@ -1408,14 +1475,20 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer): # Note that these fields are only aliased, not annotated queryset = queryset.alias( total_stock=part.filters.annotate_total_stock(reference=ref), - allocated_to_sales_orders=part.filters.annotate_sales_order_allocations(reference=ref), - allocated_to_build_orders=part.filters.annotate_build_order_allocations(reference=ref), + allocated_to_sales_orders=part.filters.annotate_sales_order_allocations( + reference=ref + ), + allocated_to_build_orders=part.filters.annotate_build_order_allocations( + reference=ref + ), ) # Calculate 'available_stock' based on previously annotated fields queryset = queryset.annotate( available_stock=ExpressionWrapper( - F('total_stock') - F('allocated_to_sales_orders') - F('allocated_to_build_orders'), + F('total_stock') + - F('allocated_to_sales_orders') + - F('allocated_to_build_orders'), output_field=models.DecimalField(), ) ) @@ -1425,14 +1498,20 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer): # Extract similar information for any 'substitute' parts queryset = queryset.alias( substitute_stock=part.filters.annotate_total_stock(reference=ref), - substitute_build_allocations=part.filters.annotate_build_order_allocations(reference=ref), - substitute_sales_allocations=part.filters.annotate_sales_order_allocations(reference=ref) + substitute_build_allocations=part.filters.annotate_build_order_allocations( + reference=ref + ), + substitute_sales_allocations=part.filters.annotate_sales_order_allocations( + reference=ref + ), ) # Calculate 'available_substitute_stock' field queryset = queryset.annotate( available_substitute_stock=ExpressionWrapper( - F('substitute_stock') - F('substitute_build_allocations') - F('substitute_sales_allocations'), + F('substitute_stock') + - F('substitute_build_allocations') + - F('substitute_sales_allocations'), output_field=models.DecimalField(), ) ) @@ -1441,14 +1520,22 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer): variant_stock_query = part.filters.variant_stock_query(reference='sub_part__') queryset = queryset.alias( - variant_stock_total=part.filters.annotate_variant_quantity(variant_stock_query, reference='quantity'), - variant_bo_allocations=part.filters.annotate_variant_quantity(variant_stock_query, reference='sales_order_allocations__quantity'), - variant_so_allocations=part.filters.annotate_variant_quantity(variant_stock_query, reference='allocations__quantity'), + variant_stock_total=part.filters.annotate_variant_quantity( + variant_stock_query, reference='quantity' + ), + variant_bo_allocations=part.filters.annotate_variant_quantity( + variant_stock_query, reference='sales_order_allocations__quantity' + ), + variant_so_allocations=part.filters.annotate_variant_quantity( + variant_stock_query, reference='allocations__quantity' + ), ) queryset = queryset.annotate( available_variant_stock=ExpressionWrapper( - F('variant_stock_total') - F('variant_bo_allocations') - F('variant_so_allocations'), + F('variant_stock_total') + - F('variant_bo_allocations') + - F('variant_so_allocations'), output_field=FloatField(), ) ) @@ -1456,11 +1543,14 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer): return queryset -class CategoryParameterTemplateSerializer(InvenTree.serializers.InvenTreeModelSerializer): +class CategoryParameterTemplateSerializer( + InvenTree.serializers.InvenTreeModelSerializer +): """Serializer for the PartCategoryParameterTemplate model.""" class Meta: """Metaclass defining serializer fields""" + model = PartCategoryParameterTemplate fields = [ 'pk', @@ -1471,7 +1561,9 @@ class CategoryParameterTemplateSerializer(InvenTree.serializers.InvenTreeModelSe 'default_value', ] - parameter_template_detail = PartParameterTemplateSerializer(source='parameter_template', many=False, read_only=True) + parameter_template_detail = PartParameterTemplateSerializer( + source='parameter_template', many=False, read_only=True + ) category_detail = CategorySerializer(source='category', many=False, read_only=True) @@ -1481,6 +1573,7 @@ class PartCopyBOMSerializer(serializers.Serializer): class Meta: """Metaclass defining serializer fields""" + fields = [ 'part', 'remove_existing', @@ -1548,22 +1641,16 @@ class BomImportUploadSerializer(InvenTree.serializers.DataFileUploadSerializer): class Meta: """Metaclass defining serializer fields""" - fields = [ - 'data_file', - 'part', - 'clear_existing_bom', - ] + + fields = ['data_file', 'part', 'clear_existing_bom'] part = serializers.PrimaryKeyRelatedField( - queryset=Part.objects.all(), - required=True, - allow_null=False, - many=False, + queryset=Part.objects.all(), required=True, allow_null=False, many=False ) clear_existing_bom = serializers.BooleanField( label=_('Clear Existing BOM'), - help_text=_('Delete existing BOM items before uploading') + help_text=_('Delete existing BOM items before uploading'), ) def save(self): @@ -1700,9 +1787,7 @@ class BomImportSubmitSerializer(serializers.Serializer): bom_items = [] try: - for item in items: - part = item['part'] sub_part = item['sub_part'] @@ -1710,9 +1795,7 @@ class BomImportSubmitSerializer(serializers.Serializer): if BomItem.objects.filter(part=part, sub_part=sub_part).exists(): continue - bom_items.append( - BomItem(**item) - ) + bom_items.append(BomItem(**item)) if len(bom_items) > 0: logger.info("Importing %s BOM items", len(bom_items)) diff --git a/InvenTree/part/stocktake.py b/InvenTree/part/stocktake.py index 7ccd6cd185..7b3d12f3b7 100644 --- a/InvenTree/part/stocktake.py +++ b/InvenTree/part/stocktake.py @@ -21,7 +21,9 @@ import stock.models logger = logging.getLogger('inventree') -def perform_stocktake(target: part.models.Part, user: User, note: str = '', commit=True, **kwargs): +def perform_stocktake( + target: part.models.Part, user: User, note: str = '', commit=True, **kwargs +): """Perform stocktake action on a single part. Arguments: @@ -78,7 +80,6 @@ def perform_stocktake(target: part.models.Part, user: User, note: str = '', comm location_cost_max = Money(0, base_currency) for entry in stock_entries: - entry_cost_min = None entry_cost_max = None @@ -94,10 +95,13 @@ def perform_stocktake(target: part.models.Part, user: User, note: str = '', comm # Convert to base currency try: - entry_cost_min = convert_money(entry_cost_min, base_currency) * entry.quantity - entry_cost_max = convert_money(entry_cost_max, base_currency) * entry.quantity + entry_cost_min = ( + convert_money(entry_cost_min, base_currency) * entry.quantity + ) + entry_cost_max = ( + convert_money(entry_cost_max, base_currency) * entry.quantity + ) except Exception: - entry_cost_min = Money(0, base_currency) entry_cost_max = Money(0, base_currency) @@ -160,7 +164,7 @@ def generate_stocktake_report(**kwargs): # Determine if external locations should be excluded exclude_external = kwargs.get( 'exclude_exernal', - common.models.InvenTreeSetting.get_setting('STOCKTAKE_EXCLUDE_EXTERNAL', False) + common.models.InvenTreeSetting.get_setting('STOCKTAKE_EXCLUDE_EXTERNAL', False), ) parts = part.models.Part.objects.all() @@ -172,9 +176,7 @@ def generate_stocktake_report(**kwargs): # Filter by 'Part' instance if p := kwargs.get('part', None): variants = p.get_descendants(include_self=True) - parts = parts.filter( - pk__in=[v.pk for v in variants] - ) + parts = parts.filter(pk__in=[v.pk for v in variants]) # Filter by 'Category' instance (cascading) if category := kwargs.get('category', None): @@ -196,9 +198,7 @@ def generate_stocktake_report(**kwargs): # List of parts which exist within these locations unique_parts = items.order_by().values('part').distinct() - parts = parts.filter( - pk__in=[result['part'] for result in unique_parts] - ) + parts = parts.filter(pk__in=[result['part'] for result in unique_parts]) # Exit if filters removed all parts n_parts = parts.count() @@ -239,12 +239,9 @@ def generate_stocktake_report(**kwargs): # Iterate through each Part which matches the filters above for p in parts: - # Create a new stocktake for this part (do not commit, this will take place later on) stocktake = perform_stocktake( - p, user, commit=False, - exclude_external=exclude_external, - location=location, + p, user, commit=False, exclude_external=exclude_external, location=location ) total_parts += 1 @@ -274,14 +271,11 @@ def generate_stocktake_report(**kwargs): if generate_report: report_instance = part.models.PartStocktakeReport.objects.create( - report=report_file, - part_count=total_parts, - user=user + report=report_file, part_count=total_parts, user=user ) # Notify the requesting user if user: - common.notifications.trigger_notification( report_instance, category='generate_stocktake_report', @@ -289,18 +283,19 @@ def generate_stocktake_report(**kwargs): 'name': _('Stocktake Report Available'), 'message': _('A new stocktake report is available for download'), }, - targets=[ - user, - ] + targets=[user], ) # If 'update_parts' is set, we save stocktake entries for each individual part if update_parts: # Use bulk_create for efficient insertion of stocktake part.models.PartStocktake.objects.bulk_create( - stocktake_instances, - batch_size=500, + stocktake_instances, batch_size=500 ) t_stocktake = time.time() - t_start - logger.info("Generated stocktake report for %s parts in %ss", total_parts, round(t_stocktake, 2)) + logger.info( + "Generated stocktake report for %s parts in %ss", + total_parts, + round(t_stocktake, 2), + ) diff --git a/InvenTree/part/tasks.py b/InvenTree/part/tasks.py index a305adeba8..2830b49640 100644 --- a/InvenTree/part/tasks.py +++ b/InvenTree/part/tasks.py @@ -1,6 +1,5 @@ """Background task definitions for the 'part' app""" - import logging import random import time @@ -18,8 +17,12 @@ import InvenTree.helpers_model import InvenTree.tasks import part.models import part.stocktake -from InvenTree.tasks import (ScheduledTask, check_daily_holdoff, - record_task_success, scheduled_task) +from InvenTree.tasks import ( + ScheduledTask, + check_daily_holdoff, + record_task_success, + scheduled_task, +) logger = logging.getLogger("inventree") @@ -31,23 +34,19 @@ def notify_low_stock(part: part.models.Part): - A notification is delivered to any users who are 'subscribed' to this part """ name = _("Low stock notification") - message = _(f'The available stock for {part.name} has fallen below the configured minimum level') + message = _( + f'The available stock for {part.name} has fallen below the configured minimum level' + ) context = { 'part': part, 'name': name, 'message': message, 'link': InvenTree.helpers_model.construct_absolute_url(part.get_absolute_url()), - 'template': { - 'html': 'email/low_stock_notification.html', - 'subject': name, - }, + 'template': {'html': 'email/low_stock_notification.html', 'subject': name}, } common.notifications.trigger_notification( - part, - 'part.notify_low_stock', - target_fnc=part.get_subscribers, - context=context, + part, 'part.notify_low_stock', target_fnc=part.get_subscribers, context=context ) @@ -61,10 +60,7 @@ def notify_low_stock_if_required(part: part.models.Part): for p in parts: if p.is_part_low_on_stock(): - InvenTree.tasks.offload_task( - notify_low_stock, - p - ) + InvenTree.tasks.offload_task(notify_low_stock, p) def update_part_pricing(pricing: part.models.PartPricing, counter: int = 0): @@ -146,7 +142,11 @@ def scheduled_stocktake_reports(): time.sleep(random.randint(1, 5)) # First let's delete any old stocktake reports - delete_n_days = int(common.models.InvenTreeSetting.get_setting('STOCKTAKE_DELETE_REPORT_DAYS', 30, cache=False)) + delete_n_days = int( + common.models.InvenTreeSetting.get_setting( + 'STOCKTAKE_DELETE_REPORT_DAYS', 30, cache=False + ) + ) threshold = datetime.now() - timedelta(days=delete_n_days) old_reports = part.models.PartStocktakeReport.objects.filter(date__lt=threshold) @@ -155,11 +155,17 @@ def scheduled_stocktake_reports(): old_reports.delete() # Next, check if stocktake functionality is enabled - if not common.models.InvenTreeSetting.get_setting('STOCKTAKE_ENABLE', False, cache=False): + if not common.models.InvenTreeSetting.get_setting( + 'STOCKTAKE_ENABLE', False, cache=False + ): logger.info("Stocktake functionality is not enabled - exiting") return - report_n_days = int(common.models.InvenTreeSetting.get_setting('STOCKTAKE_AUTO_DAYS', 0, cache=False)) + report_n_days = int( + common.models.InvenTreeSetting.get_setting( + 'STOCKTAKE_AUTO_DAYS', 0, cache=False + ) + ) if report_n_days < 1: logger.info("Stocktake auto reports are disabled, exiting") diff --git a/InvenTree/part/templatetags/i18n.py b/InvenTree/part/templatetags/i18n.py index 54af6040f6..e15b5e9697 100644 --- a/InvenTree/part/templatetags/i18n.py +++ b/InvenTree/part/templatetags/i18n.py @@ -72,7 +72,7 @@ def do_translate(parser, token): option = remaining.pop(0) if option in seen: raise TemplateSyntaxError( - "The '%s' option was specified more than once." % option, + "The '%s' option was specified more than once." % option ) elif option == "noop": noop = True @@ -87,7 +87,7 @@ def do_translate(parser, token): if value in invalid_context: raise TemplateSyntaxError( "Invalid argument '%s' provided to the '%s' tag for the context " - "option" % (value, bits[0]), + "option" % (value, bits[0]) ) message_context = parser.compile_filter(value) elif option == "as": @@ -102,10 +102,7 @@ def do_translate(parser, token): raise TemplateSyntaxError( "Unknown argument for '%s' tag: '%s'. The only options " "available are 'noop', 'context' \"xxx\", and 'as VAR'." - % ( - bits[0], - option, - ) + % (bits[0], option) ) seen.add(option) @@ -118,13 +115,21 @@ register.tag("blocktranslate", django.templatetags.i18n.do_block_translate) register.tag("language", django.templatetags.i18n.language) -register.tag("get_available_languages", django.templatetags.i18n.do_get_available_languages) +register.tag( + "get_available_languages", django.templatetags.i18n.do_get_available_languages +) register.tag("get_language_info", django.templatetags.i18n.do_get_language_info) -register.tag("get_language_info_list", django.templatetags.i18n.do_get_language_info_list) +register.tag( + "get_language_info_list", django.templatetags.i18n.do_get_language_info_list +) register.tag("get_current_language", django.templatetags.i18n.do_get_current_language) -register.tag("get_current_language_bidi", django.templatetags.i18n.do_get_current_language_bidi) +register.tag( + "get_current_language_bidi", django.templatetags.i18n.do_get_current_language_bidi +) register.filter("language_name", django.templatetags.i18n.language_name) -register.filter("language_name_translated", django.templatetags.i18n.language_name_translated) +register.filter( + "language_name_translated", django.templatetags.i18n.language_name_translated +) register.filter("language_name_local", django.templatetags.i18n.language_name_local) register.filter("language_bidi", django.templatetags.i18n.language_bidi) diff --git a/InvenTree/part/templatetags/inventree_extras.py b/InvenTree/part/templatetags/inventree_extras.py index 9abd6748bf..14cc7e7a81 100644 --- a/InvenTree/part/templatetags/inventree_extras.py +++ b/InvenTree/part/templatetags/inventree_extras.py @@ -55,7 +55,6 @@ def render_date(context, date_object): return None if isinstance(date_object, str): - date_object = date_object.strip() # Check for empty string @@ -73,22 +72,18 @@ def render_date(context, date_object): user_date_format = context.get('user_date_format', None) if user_date_format is None: - user = context.get('user', None) if user and user.is_authenticated: # User is specified - look for their date display preference - user_date_format = common.models.InvenTreeUserSetting.get_setting('DATE_DISPLAY_FORMAT', user=user) + user_date_format = common.models.InvenTreeUserSetting.get_setting( + 'DATE_DISPLAY_FORMAT', user=user + ) else: user_date_format = 'YYYY-MM-DD' # Convert the format string to Pythonic equivalent - replacements = [ - ('YYYY', '%Y'), - ('MMM', '%b'), - ('MM', '%m'), - ('DD', '%d'), - ] + replacements = [('YYYY', '%Y'), ('MMM', '%b'), ('MM', '%m'), ('DD', '%d')] for o, n in replacements: user_date_format = user_date_format.replace(o, n) @@ -171,11 +166,8 @@ def plugins_info(*args, **kwargs): plug_list = [plg for plg in registry.plugins.values() if plg.plugin_config().active] # Format list return [ - { - 'name': plg.name, - 'slug': plg.slug, - 'version': plg.version - } for plg in plug_list + {'name': plg.name, 'slug': plg.slug, 'version': plg.version} + for plg in plug_list ] @@ -344,13 +336,19 @@ def setting_object(key, *args, **kwargs): if issubclass(plg.__class__, InvenTreePlugin): plg = plg.plugin_config() - return plugin.models.PluginSetting.get_setting_object(key, plugin=plg, cache=cache) + return plugin.models.PluginSetting.get_setting_object( + key, plugin=plg, cache=cache + ) elif 'method' in kwargs: - return plugin.models.NotificationUserSetting.get_setting_object(key, user=kwargs['user'], method=kwargs['method'], cache=cache) + return plugin.models.NotificationUserSetting.get_setting_object( + key, user=kwargs['user'], method=kwargs['method'], cache=cache + ) elif 'user' in kwargs: - return common.models.InvenTreeUserSetting.get_setting_object(key, user=kwargs['user'], cache=cache) + return common.models.InvenTreeUserSetting.get_setting_object( + key, user=kwargs['user'], cache=cache + ) else: return common.models.InvenTreeSetting.get_setting_object(key, cache=cache) @@ -360,7 +358,9 @@ def setting_object(key, *args, **kwargs): def settings_value(key, *args, **kwargs): """Return a settings value specified by the given key.""" if 'user' in kwargs: - if not kwargs['user'] or (kwargs['user'] and kwargs['user'].is_authenticated is False): + if not kwargs['user'] or ( + kwargs['user'] and kwargs['user'].is_authenticated is False + ): return common.models.InvenTreeUserSetting.get_setting(key) return common.models.InvenTreeUserSetting.get_setting(key, user=kwargs['user']) @@ -465,10 +465,7 @@ def get_available_themes(*args, **kwargs): from common.models import ColorTheme for key, name in ColorTheme.get_color_themes_choices(): - themes.append({ - 'key': key, - 'name': name - }) + themes.append({'key': key, 'name': name}) return themes @@ -570,7 +567,6 @@ class I18nStaticNode(StaticNode): self.original = self.path.var if hasattr(context, 'request'): - # Convert the "requested" language code to a standard format language_code = context.request.LANGUAGE_CODE.lower().strip() language_code = language_code.replace('_', '-') @@ -579,16 +575,11 @@ class I18nStaticNode(StaticNode): # - First, try the original requested code, e.g. 'pt-br' # - Next, try a simpler version of the code e.g. 'pt' # - Finally, fall back to english - options = [ - language_code, - language_code.split('-')[0], - 'en', - ] + options = [language_code, language_code.split('-')[0], 'en'] for lng in options: lng_file = os.path.join( - djangosettings.STATIC_ROOT, - self.original.format(lng=lng) + djangosettings.STATIC_ROOT, self.original.format(lng=lng) ) if os.path.exists(lng_file): diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 78ab67a9ef..7baa291496 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -20,13 +20,20 @@ import company.models import order.models from common.models import InvenTreeSetting from company.models import Company, SupplierPart -from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatusGroups, - StockStatus) +from InvenTree.status_codes import BuildStatus, PurchaseOrderStatusGroups, StockStatus from InvenTree.unit_test import InvenTreeAPITestCase -from part.models import (BomItem, BomItemSubstitute, Part, PartCategory, - PartCategoryParameterTemplate, PartParameter, - PartParameterTemplate, PartRelated, PartStocktake, - PartTestTemplate) +from part.models import ( + BomItem, + BomItemSubstitute, + Part, + PartCategory, + PartCategoryParameterTemplate, + PartParameter, + PartParameterTemplate, + PartRelated, + PartStocktake, + PartTestTemplate, +) from stock.models import StockItem, StockLocation @@ -72,19 +79,67 @@ class PartCategoryAPITest(InvenTreeAPITestCase): ({'parent': 1, 'cascade': True}, 5, 'Filter by parent, cascading'), ({'cascade': True, 'depth': 0}, 8, 'Cascade with no parent, depth=0'), ({'cascade': False, 'depth': 10}, 8, 'Cascade with no parent, depth=0'), - ({'parent': 'null', 'cascade': True, 'depth': 0}, 2, 'Cascade with null parent, depth=0'), - ({'parent': 'null', 'cascade': True, 'depth': 10}, 8, 'Cascade with null parent and bigger depth'), - ({'parent': 'null', 'cascade': False, 'depth': 10}, 2, 'No cascade even with depth specified with null parent'), - ({'parent': 1, 'cascade': False, 'depth': 0}, 3, 'Dont cascade with depth=0 and parent'), - ({'parent': 1, 'cascade': True, 'depth': 0}, 3, 'Cascade with depth=0 and parent'), - ({'parent': 1, 'cascade': False, 'depth': 1}, 3, 'Dont cascade even with depth=1 specified with parent'), - ({'parent': 1, 'cascade': True, 'depth': 1}, 5, 'Cascade with depth=1 with parent'), - ({'parent': 1, 'cascade': True, 'depth': 'abcdefg'}, 5, 'Cascade with invalid depth and parent'), + ( + {'parent': 'null', 'cascade': True, 'depth': 0}, + 2, + 'Cascade with null parent, depth=0', + ), + ( + {'parent': 'null', 'cascade': True, 'depth': 10}, + 8, + 'Cascade with null parent and bigger depth', + ), + ( + {'parent': 'null', 'cascade': False, 'depth': 10}, + 2, + 'No cascade even with depth specified with null parent', + ), + ( + {'parent': 1, 'cascade': False, 'depth': 0}, + 3, + 'Dont cascade with depth=0 and parent', + ), + ( + {'parent': 1, 'cascade': True, 'depth': 0}, + 3, + 'Cascade with depth=0 and parent', + ), + ( + {'parent': 1, 'cascade': False, 'depth': 1}, + 3, + 'Dont cascade even with depth=1 specified with parent', + ), + ( + {'parent': 1, 'cascade': True, 'depth': 1}, + 5, + 'Cascade with depth=1 with parent', + ), + ( + {'parent': 1, 'cascade': True, 'depth': 'abcdefg'}, + 5, + 'Cascade with invalid depth and parent', + ), ({'parent': 42}, 8, 'Should return everything if parent_pk is not valid'), - ({'parent': 'null', 'exclude_tree': 1, 'cascade': True}, 2, 'Should return everything from except tree with pk=1'), - ({'parent': 'null', 'exclude_tree': 42, 'cascade': True}, 8, 'Should return everything because exclude_tree=42 is no valid pk'), - ({'parent': 1, 'starred': True, 'cascade': True}, 2, 'Should return the starred categories for the current user within the pk=1 tree'), - ({'parent': 1, 'starred': False, 'cascade': True}, 3, 'Should return the not starred categories for the current user within the pk=1 tree'), + ( + {'parent': 'null', 'exclude_tree': 1, 'cascade': True}, + 2, + 'Should return everything from except tree with pk=1', + ), + ( + {'parent': 'null', 'exclude_tree': 42, 'cascade': True}, + 8, + 'Should return everything because exclude_tree=42 is no valid pk', + ), + ( + {'parent': 1, 'starred': True, 'cascade': True}, + 2, + 'Should return the starred categories for the current user within the pk=1 tree', + ), + ( + {'parent': 1, 'starred': False, 'cascade': True}, + 3, + 'Should return the not starred categories for the current user within the pk=1 tree', + ), ] for params, res_len, description in test_cases: @@ -101,13 +156,15 @@ class PartCategoryAPITest(InvenTreeAPITestCase): 'parent', 'part_count', 'pathstring', - 'url' + 'url', ] response = self.get(url, expected_code=200) for result in response.data: for f in fields: - self.assertIn(f, result, f'"{f}" is missing in result of PartCategory list') + self.assertIn( + f, result, f'"{f}" is missing in result of PartCategory list' + ) def test_part_count(self): """Test that the 'part_count' field is annotated correctly""" @@ -115,17 +172,13 @@ class PartCategoryAPITest(InvenTreeAPITestCase): # Create a parent category cat = PartCategory.objects.create( - name='Parent Cat', - description='Some name', - parent=None + name='Parent Cat', description='Some name', parent=None ) # Create child categories for ii in range(10): child = PartCategory.objects.create( - name=f"Child cat {ii}", - description="A child category", - parent=cat + name=f"Child cat {ii}", description="A child category", parent=cat ) # Create parts in this category @@ -133,17 +186,11 @@ class PartCategoryAPITest(InvenTreeAPITestCase): Part.objects.create( name=f"Part xyz {jj}_{ii}", description="A test part with a description", - category=child + category=child, ) # Filter by parent category - response = self.get( - url, - { - 'parent': cat.pk, - }, - expected_code=200 - ) + response = self.get(url, {'parent': cat.pk}, expected_code=200) # 10 child categories self.assertEqual(len(response.data), 10) @@ -153,10 +200,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase): self.assertEqual(result['part_count'], 10) # Detail view for parent category - response = self.get( - f'/api/part/category/{cat.pk}/', - expected_code=200 - ) + response = self.get(f'/api/part/category/{cat.pk}/', expected_code=200) # Annotation should include parts from all sub-categories self.assertEqual(response.data['part_count'], 100) @@ -179,7 +223,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase): 'category': 2, 'parameter_template': template.pk, 'default_value': 'xyz', - } + }, ) # Total number of category templates should have increased @@ -187,22 +231,26 @@ class PartCategoryAPITest(InvenTreeAPITestCase): self.assertEqual(len(response.data), 2 + n) # Filter by category - response = self.get( - url, - { - 'category': 2, - } - ) + response = self.get(url, {'category': 2}) self.assertEqual(len(response.data), n) # Test that we can retrieve individual templates via the API for template in PartCategoryParameterTemplate.objects.all(): - url = reverse('api-part-category-parameter-detail', kwargs={'pk': template.pk}) + url = reverse( + 'api-part-category-parameter-detail', kwargs={'pk': template.pk} + ) data = self.get(url, {}, expected_code=200).data - for key in ['pk', 'category', 'category_detail', 'parameter_template', 'parameter_template_detail', 'default_value']: + for key in [ + 'pk', + 'category', + 'category_detail', + 'parameter_template', + 'parameter_template_detail', + 'default_value', + ]: self.assertIn(key, data.keys()) # Test that we can delete via the API also @@ -227,31 +275,15 @@ class PartCategoryAPITest(InvenTreeAPITestCase): ] for v in invalid_values: - response = self.patch( - url, - { - 'description': v - }, - expected_code=400 - ) + response = self.patch(url, {'description': v}, expected_code=400) self.assertIn('Remove HTML tags', str(response.data)) # Raw characters should be allowed - allowed = [ - '<< hello', - 'Alpha & Omega', - 'A > B > C', - ] + allowed = ['<< hello', 'Alpha & Omega', 'A > B > C'] for val in allowed: - response = self.patch( - url, - { - 'description': val, - }, - expected_code=200, - ) + response = self.patch(url, {'description': val}, expected_code=200) self.assertEqual(response.data['description'], val) @@ -263,18 +295,11 @@ class PartCategoryAPITest(InvenTreeAPITestCase): 'A part\n category\n\t', 'A\t part\t category\t', 'A pa\rrt cat\r\r\regory', - 'A part\u200e catego\u200fry\u202e' + 'A part\u200e catego\u200fry\u202e', ] for val in values: - - response = self.patch( - url, - { - 'description': val, - }, - expected_code=200, - ) + response = self.patch(url, {'description': val}, expected_code=200) self.assertEqual(response.data['description'], 'A part category') @@ -282,25 +307,31 @@ class PartCategoryAPITest(InvenTreeAPITestCase): """Test category deletion with different parameters""" class Target(IntEnum): - move_subcategories_to_parent_move_parts_to_parent = 0, - move_subcategories_to_parent_delete_parts = 1, - delete_subcategories_move_parts_to_parent = 2, - delete_subcategories_delete_parts = 3, + move_subcategories_to_parent_move_parts_to_parent = (0,) + move_subcategories_to_parent_delete_parts = (1,) + delete_subcategories_move_parts_to_parent = (2,) + delete_subcategories_delete_parts = (3,) for i in range(4): delete_child_categories: bool = False delete_parts: bool = False - if i in (Target.move_subcategories_to_parent_delete_parts, Target.delete_subcategories_delete_parts): + if i in ( + Target.move_subcategories_to_parent_delete_parts, + Target.delete_subcategories_delete_parts, + ): delete_parts = True - if i in (Target.delete_subcategories_move_parts_to_parent, Target.delete_subcategories_delete_parts): + if i in ( + Target.delete_subcategories_move_parts_to_parent, + Target.delete_subcategories_delete_parts, + ): delete_child_categories = True # Create a parent category parent_category = PartCategory.objects.create( name='Parent category', description='This is the parent category where the child categories and parts are moved to', - parent=None + parent=None, ) category_count_before = PartCategory.objects.count() @@ -310,7 +341,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase): cat_to_delete = PartCategory.objects.create( name='Category to delete', description='This is the category to be deleted', - parent=parent_category + parent=parent_category, ) url = reverse('api-part-category-detail', kwargs={'pk': cat_to_delete.id}) @@ -318,11 +349,13 @@ class PartCategoryAPITest(InvenTreeAPITestCase): parts = [] # Create parts in the category to be deleted for jj in range(3): - parts.append(Part.objects.create( - name=f"Part xyz {i}_{jj}", - description="Child part of the deleted category", - category=cat_to_delete - )) + parts.append( + Part.objects.create( + name=f"Part xyz {i}_{jj}", + description="Child part of the deleted category", + category=cat_to_delete, + ) + ) child_categories = [] child_categories_parts = [] @@ -331,17 +364,19 @@ class PartCategoryAPITest(InvenTreeAPITestCase): child = PartCategory.objects.create( name=f"Child parent_cat {i}_{ii}", description="A child category of the deleted category", - parent=cat_to_delete + parent=cat_to_delete, ) child_categories.append(child) # Create parts in the child categories for jj in range(3): - child_categories_parts.append(Part.objects.create( - name=f"Part xyz {i}_{jj}_{ii}", - description="Child part in the child category of the deleted category", - category=child - )) + child_categories_parts.append( + Part.objects.create( + name=f"Part xyz {i}_{jj}_{ii}", + description="Child part in the child category of the deleted category", + category=child, + ) + ) # Delete the created category (sub categories and their parts will be moved under the parent) params = {} @@ -349,11 +384,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase): params['delete_parts'] = '1' if delete_child_categories: params['delete_child_categories'] = '1' - response = self.delete( - url, - params, - expected_code=204, - ) + response = self.delete(url, params, expected_code=204) self.assertEqual(response.status_code, 204) @@ -363,7 +394,10 @@ class PartCategoryAPITest(InvenTreeAPITestCase): self.assertEqual(Part.objects.count(), part_count_before) elif i == Target.move_subcategories_to_parent_delete_parts: # Check if all parts deleted - self.assertEqual(Part.objects.count(), part_count_before + len(child_categories_parts)) + self.assertEqual( + Part.objects.count(), + part_count_before + len(child_categories_parts), + ) else: # parts moved to the parent category for part in parts: @@ -396,7 +430,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase): name='Structural category', description='This is the structural category', parent=None, - structural=True + structural=True, ) part_count_before = Part.objects.count() @@ -406,7 +440,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase): part = Part.objects.create( name="-", description="Part which shall not be created", - category=structural_category + category=structural_category, ) # Ensure that the part really did not get created in the structural category @@ -417,14 +451,14 @@ class PartCategoryAPITest(InvenTreeAPITestCase): name='Non-structural category', description='This is a non-structural category', parent=None, - structural=False + structural=False, ) # Create the test part assigned to a non-structural category part = Part.objects.create( name="-", description="Part which category will be changed to structural", - category=non_structural_category + category=non_structural_category, ) # Assign the test part to a structural category and make sure it gives an error @@ -441,25 +475,13 @@ class PartCategoryAPITest(InvenTreeAPITestCase): url = reverse('api-part-category-detail', kwargs={'pk': 5}) # First, request without path detail - response = self.get( - url, - { - 'path_detail': False, - }, - expected_code=200 - ) + response = self.get(url, {'path_detail': False}, expected_code=200) # Check that the path detail information is not included self.assertFalse('path' in response.data.keys()) # Now, request *with* path detail - response = self.get( - url, - { - 'path_detail': True, - }, - expected_code=200 - ) + response = self.get(url, {'path_detail': True}, expected_code=200) self.assertTrue('path' in response.data.keys()) @@ -477,9 +499,7 @@ class PartOptionsAPITest(InvenTreeAPITestCase): Ensure that the required field details are provided! """ - roles = [ - 'part.add', - ] + roles = ['part.add'] def test_part(self): """Test the Part API OPTIONS.""" @@ -611,43 +631,23 @@ class PartAPITest(PartAPITestBase): self.assertEqual(len(response.data), 8) # Request top-level part categories only - response = self.get( - url, - { - 'parent': 'null', - } - ) + response = self.get(url, {'parent': 'null'}) self.assertEqual(len(response.data), 2) # Children of PartCategory<1>, cascade - response = self.get( - url, - { - 'parent': 1, - 'cascade': 'true', - } - ) + response = self.get(url, {'parent': 1, 'cascade': 'true'}) self.assertEqual(len(response.data), 5) # Children of PartCategory<1>, do not cascade - response = self.get( - url, - { - 'parent': 1, - 'cascade': 'false', - } - ) + response = self.get(url, {'parent': 1, 'cascade': 'false'}) self.assertEqual(len(response.data), 3) def test_add_categories(self): """Check that we can add categories.""" - data = { - 'name': 'Animals', - 'description': 'All animals go here' - } + data = {'name': 'Animals', 'description': 'All animals go here'} url = reverse('api-part-category-list') response = self.post(url, data) @@ -657,11 +657,7 @@ class PartAPITest(PartAPITestBase): # Add some sub-categories to the top-level 'Animals' category for animal in ['cat', 'dog', 'zebra']: - data = { - 'name': animal, - 'description': 'A sort of animal', - 'parent': parent, - } + data = {'name': animal, 'description': 'A sort of animal', 'parent': parent} response = self.post(url, data) self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.data['parent'], parent) @@ -714,13 +710,7 @@ class PartAPITest(PartAPITestBase): """Test that we can filter part list by the 'in_bom_for' parameter""" url = reverse('api-part-list') - response = self.get( - url, - { - 'in_bom_for': 100, - }, - expected_code=200, - ) + response = self.get(url, {'in_bom_for': 100}, expected_code=200) self.assertEqual(len(response.data), 4) @@ -734,13 +724,11 @@ class PartAPITest(PartAPITestBase): # Add some relationships PartRelated.objects.create( - part_1=Part.objects.get(pk=1), - part_2=Part.objects.get(pk=2), + part_1=Part.objects.get(pk=1), part_2=Part.objects.get(pk=2) ) PartRelated.objects.create( - part_2=Part.objects.get(pk=1), - part_1=Part.objects.get(pk=3) + part_2=Part.objects.get(pk=1), part_1=Part.objects.get(pk=3) ) response = self.get(url, {'related': 1}, expected_code=200) @@ -752,7 +740,8 @@ class PartAPITest(PartAPITestBase): # First, construct a set of template / variant parts master_part = Part.objects.create( - name='Master', description='Master part which has some variants', + name='Master', + description='Master part which has some variants', category=category, is_template=True, ) @@ -761,33 +750,24 @@ class PartAPITest(PartAPITestBase): variants = [] for color in ['Red', 'Green', 'Blue', 'Yellow', 'Pink', 'Black']: - variants.append(Part.objects.create( - name=f"{color} Variant", description="Variant part with a specific color", - variant_of=master_part, - category=category, - )) + variants.append( + Part.objects.create( + name=f"{color} Variant", + description="Variant part with a specific color", + variant_of=master_part, + category=category, + ) + ) url = reverse('api-part-list') # An invalid part ID will return an error - response = self.get( - url, - { - 'convert_from': 999999, - }, - expected_code=400 - ) + response = self.get(url, {'convert_from': 999999}, expected_code=400) self.assertIn('Select a valid choice', str(response.data['convert_from'])) for variant in variants: - response = self.get( - url, - { - 'convert_from': variant.pk, - }, - expected_code=200, - ) + response = self.get(url, {'convert_from': variant.pk}, expected_code=200) # There should be the same number of results for each request self.assertEqual(len(response.data), 6) @@ -838,11 +818,7 @@ class PartAPITest(PartAPITestBase): # Try to post a new object (missing description) response = self.post( url, - data={ - 'part': 10000, - 'test_name': 'My very first test', - 'required': False, - } + data={'part': 10000, 'test_name': 'My very first test', 'required': False}, ) self.assertEqual(response.status_code, 400) @@ -854,8 +830,8 @@ class PartAPITest(PartAPITestBase): 'part': 10000, 'test_name': 'New Test', 'required': True, - 'description': 'a test description' - } + 'description': 'a test description', + }, ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -863,23 +839,13 @@ class PartAPITest(PartAPITestBase): # Try to post a new test with the same name (should fail) response = self.post( url, - data={ - 'part': 10004, - 'test_name': " newtest", - 'description': 'dafsdf', - } + data={'part': 10004, 'test_name': " newtest", 'description': 'dafsdf'}, ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) # Try to post a new test against a non-trackable part (should fail) - response = self.post( - url, - data={ - 'part': 1, - 'test_name': 'A simple test', - } - ) + response = self.post(url, data={'part': 1, 'test_name': 'A simple test'}) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -916,46 +882,22 @@ class PartAPITest(PartAPITestBase): url = reverse('api-part-list') - response = self.get( - url, - { - 'variant_of': 10000, - }, - expected_code=200 - ) + response = self.get(url, {'variant_of': 10000}, expected_code=200) # 3 direct children of template part self.assertEqual(len(response.data), 3) - response = self.get( - url, - { - 'ancestor': 10000, - }, - expected_code=200, - ) + response = self.get(url, {'ancestor': 10000}, expected_code=200) # 4 total descendants self.assertEqual(len(response.data), 4) # Use the 'green chair' as our reference - response = self.get( - url, - { - 'variant_of': 10003, - }, - expected_code=200, - ) + response = self.get(url, {'variant_of': 10003}, expected_code=200) self.assertEqual(len(response.data), 1) - response = self.get( - url, - { - 'ancestor': 10003, - }, - expected_code=200, - ) + response = self.get(url, {'ancestor': 10003}, expected_code=200) self.assertEqual(len(response.data), 1) @@ -971,24 +913,12 @@ class PartAPITest(PartAPITestBase): ) # There should still be only one direct variant - response = self.get( - url, - { - 'variant_of': 10003, - }, - expected_code=200, - ) + response = self.get(url, {'variant_of': 10003}, expected_code=200) self.assertEqual(len(response.data), 1) # However, now should be 101 descendants - response = self.get( - url, - { - 'ancestor': 10003, - }, - expected_code=200, - ) + response = self.get(url, {'ancestor': 10003}, expected_code=200) self.assertEqual(len(response.data), 101) @@ -1008,13 +938,7 @@ class PartAPITest(PartAPITestBase): self.assertEqual(response.data['variant_stock'], 0) # Grab a list of all variant chairs *under* the master template - response = self.get( - url, - { - 'ancestor': 10000, - }, - expected_code=200, - ) + response = self.get(url, {'ancestor': 10000}, expected_code=200) # 4 total descendants self.assertEqual(len(response.data), 4) @@ -1024,10 +948,7 @@ class PartAPITest(PartAPITestBase): # Now, let's make some variant stock for variant in Part.objects.get(pk=10000).get_descendants(include_self=False): - StockItem.objects.create( - part=variant, - quantity=100, - ) + StockItem.objects.create(part=variant, quantity=100) response = self.get('/api/part/10000/', {}, expected_code=200) @@ -1035,24 +956,15 @@ class PartAPITest(PartAPITestBase): self.assertEqual(response.data['variant_stock'], 400) # Check that each variant reports the correct stock quantities - response = self.get( - url, - { - 'ancestor': 10000, - }, - expected_code=200, - ) + response = self.get(url, {'ancestor': 10000}, expected_code=200) - expected_variant_stock = { - 10001: 0, - 10002: 0, - 10003: 100, - 10004: 0, - } + expected_variant_stock = {10001: 0, 10002: 0, 10003: 100, 10004: 0} for variant in response.data: self.assertEqual(variant['in_stock'], 100) - self.assertEqual(variant['variant_stock'], expected_variant_stock[variant['pk']]) + self.assertEqual( + variant['variant_stock'], expected_variant_stock[variant['pk']] + ) # Add some 'sub variants' for the green chair variant green_chair = Part.objects.get(pk=10004) @@ -1064,10 +976,7 @@ class PartAPITest(PartAPITestBase): variant_of=green_chair, ) - StockItem.objects.create( - part=gcv, - quantity=50, - ) + StockItem.objects.create(part=gcv, quantity=50) # Spot check of some values response = self.get('/api/part/10000/', {}) @@ -1095,19 +1004,11 @@ class PartAPITest(PartAPITestBase): 'creation_date', ] - excluded_cols = [ - 'lft', 'rght', 'level', 'tree_id', - 'metadata', - ] + excluded_cols = ['lft', 'rght', 'level', 'tree_id', 'metadata'] with self.download_file( - url, - { - 'export': 'csv', - }, - expected_fn='InvenTree_Parts.csv', + url, {'export': 'csv'}, expected_fn='InvenTree_Parts.csv' ) as file: - data = self.process_csv( file, excluded_cols=excluded_cols, @@ -1139,13 +1040,7 @@ class PartAPITest(PartAPITestBase): date_compare = datetime.fromisoformat('2019-01-01') # Filter by creation date - response = self.get( - url, - { - 'created_before': '2019-01-01', - }, - expected_code=200 - ) + response = self.get(url, {'created_before': '2019-01-01'}, expected_code=200) self.assertTrue(len(response.data) < n) self.assertTrue(len(response.data) > 0) @@ -1156,13 +1051,7 @@ class PartAPITest(PartAPITestBase): date = datetime.fromisoformat(item['creation_date']) self.assertLessEqual(date, date_compare) - response = self.get( - url, - { - 'created_after': '2019-01-01', - }, - expected_code=200 - ) + response = self.get(url, {'created_after': '2019-01-01'}, expected_code=200) self.assertTrue(len(response.data) < n) self.assertTrue(len(response.data) > 0) @@ -1187,11 +1076,7 @@ class PartCreationTests(PartAPITestBase): response = self.post( url, - { - 'name': 'all defaults', - 'description': 'my test part', - 'category': 1, - }, + {'name': 'all defaults', 'description': 'my test part', 'category': 1}, expected_code=201, ) @@ -1205,19 +1090,11 @@ class PartCreationTests(PartAPITestBase): self.assertTrue(data['purchaseable']) # Set the default 'purchaseable' status to True - InvenTreeSetting.set_setting( - 'PART_PURCHASEABLE', - True, - self.user - ) + InvenTreeSetting.set_setting('PART_PURCHASEABLE', True, self.user) response = self.post( url, - { - 'name': 'all defaults 2', - 'description': 'my test part 2', - 'category': 1, - }, + {'name': 'all defaults 2', 'description': 'my test part 2', 'category': 1}, expected_code=201, ) @@ -1234,7 +1111,7 @@ class PartCreationTests(PartAPITestBase): 'active': False, 'purchaseable': False, }, - expected_code=201 + expected_code=201, ) self.assertFalse(response.data['active']) @@ -1254,9 +1131,7 @@ class PartCreationTests(PartAPITestBase): data['initial_stock'] = stock_data response = self.post( - reverse('api-part-list'), - data, - expected_code=expected_code + reverse('api-part-list'), data, expected_code=expected_code ) return response.data @@ -1266,19 +1141,18 @@ class PartCreationTests(PartAPITestBase): # Submit with empty data response = submit({}, expected_code=400) - self.assertIn('This field is required', str(response['initial_stock']['quantity'])) + self.assertIn( + 'This field is required', str(response['initial_stock']['quantity']) + ) # Submit with invalid quantity - response = submit({ - 'quantity': 'ax', - }, expected_code=400) - self.assertIn('A valid number is required', str(response['initial_stock']['quantity'])) + response = submit({'quantity': 'ax'}, expected_code=400) + self.assertIn( + 'A valid number is required', str(response['initial_stock']['quantity']) + ) # Submit with valid data - response = submit({ - 'quantity': 50, - 'location': 1, - }, expected_code=201) + response = submit({'quantity': 50, 'location': 1}, expected_code=201) part = Part.objects.get(pk=response['pk']) self.assertEqual(part.total_stock, 50) @@ -1298,9 +1172,7 @@ class PartCreationTests(PartAPITestBase): data['initial_supplier'] = supplier_data response = self.post( - reverse('api-part-list'), - data, - expected_code=expected_code + reverse('api-part-list'), data, expected_code=expected_code ) return response.data @@ -1310,46 +1182,41 @@ class PartCreationTests(PartAPITestBase): n_sp = company.models.SupplierPart.objects.count() # Submit with an invalid manufacturer - response = submit({ - 'manufacturer': 99999, - }) + response = submit({'manufacturer': 99999}) - self.assertIn('object does not exist', str(response['initial_supplier']['manufacturer'])) + self.assertIn( + 'object does not exist', str(response['initial_supplier']['manufacturer']) + ) - response = submit({ - 'manufacturer': 8 - }) + response = submit({'manufacturer': 8}) - self.assertIn('Selected company is not a valid manufacturer', str(response['initial_supplier']['manufacturer'])) + self.assertIn( + 'Selected company is not a valid manufacturer', + str(response['initial_supplier']['manufacturer']), + ) # Submit with an invalid supplier - response = submit({ - 'supplier': 8, - }) + response = submit({'supplier': 8}) - self.assertIn('Selected company is not a valid supplier', str(response['initial_supplier']['supplier'])) + self.assertIn( + 'Selected company is not a valid supplier', + str(response['initial_supplier']['supplier']), + ) # Test for duplicate MPN - response = submit({ - 'manufacturer': 6, - 'mpn': 'MPN123', - }) + response = submit({'manufacturer': 6, 'mpn': 'MPN123'}) - self.assertIn('Manufacturer part matching this MPN already exists', str(response)) + self.assertIn( + 'Manufacturer part matching this MPN already exists', str(response) + ) # Test for duplicate SKU - response = submit({ - 'supplier': 2, - 'sku': 'MPN456-APPEL', - }) + response = submit({'supplier': 2, 'sku': 'MPN456-APPEL'}) self.assertIn('Supplier part matching this SKU already exists', str(response)) # Test fields which are too long - response = submit({ - 'sku': 'abc' * 100, - 'mpn': 'xyz' * 100, - }) + response = submit({'sku': 'abc' * 100, 'mpn': 'xyz' * 100}) too_long = 'Ensure this field has no more than 100 characters' @@ -1358,13 +1225,8 @@ class PartCreationTests(PartAPITestBase): # Finally, submit a valid set of information response = submit( - { - 'supplier': 2, - 'sku': 'ABCDEFG', - 'manufacturer': 6, - 'mpn': 'QWERTY' - }, - expected_code=201 + {'supplier': 2, 'sku': 'ABCDEFG', 'manufacturer': 6, 'mpn': 'QWERTY'}, + expected_code=201, ) self.assertEqual(n_part + 1, Part.objects.count()) @@ -1378,11 +1240,7 @@ class PartCreationTests(PartAPITestBase): name = "Kaltgerätestecker" description = "Gerät Kaltgerätestecker strange chars should get through" - data = { - "name": name, - "description": description, - "category": 2 - } + data = {"name": name, "description": description, "category": 2} response = self.post(url, data, expected_code=201) @@ -1406,7 +1264,7 @@ class PartCreationTests(PartAPITestBase): 'copy_bom': bom, 'copy_image': img, 'copy_parameters': params, - } + }, }, expected_code=201, ) @@ -1426,7 +1284,7 @@ class PartCreationTests(PartAPITestBase): PartCategoryParameterTemplate.objects.create( parameter_template=PartParameterTemplate.objects.get(pk=pk), category=cat, - default_value=f"Value {pk}" + default_value=f"Value {pk}", ) self.assertEqual(cat.parameter_templates.count(), 3) @@ -1487,7 +1345,7 @@ class PartDetailTests(PartAPITestBase): 'description': 'a part created with the API', 'category': 1, 'tags': '["tag1", "tag2"]', - } + }, ) self.assertEqual(response.status_code, 201) @@ -1508,9 +1366,7 @@ class PartDetailTests(PartAPITestBase): # Let's change the name of the part - response = self.patch(url, { - 'name': 'a new better name', - }) + response = self.patch(url, {'name': 'a new better name'}) self.assertEqual(response.status_code, 200) self.assertEqual(response.data['pk'], pk) @@ -1526,16 +1382,12 @@ class PartDetailTests(PartAPITestBase): # Now, try to set the name to the *same* value # 2021-06-22 this test is to check that the "duplicate part" checks don't do strange things - response = self.patch(url, { - 'name': 'a new better name', - }) + response = self.patch(url, {'name': 'a new better name'}) self.assertEqual(response.status_code, 200) # Try to remove a tag - response = self.patch(url, { - 'tags': ['tag1',], - }) + response = self.patch(url, {'tags': ['tag1']}) self.assertEqual(response.status_code, 200) self.assertEqual(response.data['tags'], ['tag1']) @@ -1557,34 +1409,38 @@ class PartDetailTests(PartAPITestBase): def test_duplicates(self): """Check that trying to create 'duplicate' parts results in errors.""" # Create a part - response = self.post(reverse('api-part-list'), { - 'name': 'part', - 'description': 'description', - 'IPN': 'IPN-123', - 'category': 1, - 'revision': 'A', - }) + response = self.post( + reverse('api-part-list'), + { + 'name': 'part', + 'description': 'description', + 'IPN': 'IPN-123', + 'category': 1, + 'revision': 'A', + }, + ) self.assertEqual(response.status_code, 201) n = Part.objects.count() # Check that we cannot create a duplicate in a different category - response = self.post(reverse('api-part-list'), { - 'name': 'part', - 'description': 'description', - 'IPN': 'IPN-123', - 'category': 2, - 'revision': 'A', - }) + response = self.post( + reverse('api-part-list'), + { + 'name': 'part', + 'description': 'description', + 'IPN': 'IPN-123', + 'category': 2, + 'revision': 'A', + }, + ) self.assertEqual(response.status_code, 400) # Check that only 1 matching part exists parts = Part.objects.filter( - name='part', - description='description', - IPN='IPN-123' + name='part', description='description', IPN='IPN-123' ) self.assertEqual(parts.count(), 1) @@ -1593,13 +1449,16 @@ class PartDetailTests(PartAPITestBase): self.assertEqual(Part.objects.count(), n) # But a different 'revision' *can* be created - response = self.post(reverse('api-part-list'), { - 'name': 'part', - 'description': 'description', - 'IPN': 'IPN-123', - 'category': 2, - 'revision': 'B', - }) + response = self.post( + reverse('api-part-list'), + { + 'name': 'part', + 'description': 'description', + 'IPN': 'IPN-123', + 'category': 2, + 'revision': 'B', + }, + ) self.assertEqual(response.status_code, 201) self.assertEqual(Part.objects.count(), n + 1) @@ -1610,22 +1469,12 @@ class PartDetailTests(PartAPITestBase): url = reverse('api-part-detail', kwargs={'pk': pk}) # Attempt to alter the revision code - response = self.patch( - url, - { - 'revision': 'A', - } - ) + response = self.patch(url, {'revision': 'A'}) self.assertEqual(response.status_code, 400) # But we *can* change it to a unique revision code - response = self.patch( - url, - { - 'revision': 'C', - } - ) + response = self.patch(url, {'revision': 'C'}) self.assertEqual(response.status_code, 200) @@ -1636,12 +1485,8 @@ class PartDetailTests(PartAPITestBase): # Create a new part response = self.post( reverse('api-part-list'), - { - 'name': 'imagine', - 'description': 'All the people', - 'category': 1, - }, - expected_code=201 + {'name': 'imagine', 'description': 'All the people', 'category': 1}, + expected_code=201, ) pk = response.data['pk'] @@ -1660,11 +1505,7 @@ class PartDetailTests(PartAPITestBase): with open('dummy_image.txt', 'rb') as dummy_image: response = self.upload_client.patch( - url, - { - 'image': dummy_image, - }, - format='multipart', + url, {'image': dummy_image}, format='multipart' ) self.assertEqual(response.status_code, 400) @@ -1679,11 +1520,7 @@ class PartDetailTests(PartAPITestBase): with open(fn, 'rb') as dummy_image: response = self.upload_client.patch( - url, - { - 'image': dummy_image, - }, - format='multipart', + url, {'image': dummy_image}, format='multipart' ) self.assertEqual(response.status_code, 200) @@ -1705,10 +1542,7 @@ class PartDetailTests(PartAPITestBase): with open(fn, 'rb') as img_file: response = self.upload_client.patch( - reverse('api-part-detail', kwargs={'pk': p.pk}), - { - 'image': img_file, - }, + reverse('api-part-detail', kwargs={'pk': p.pk}), {'image': img_file} ) self.assertEqual(response.status_code, 200) @@ -1724,7 +1558,7 @@ class PartDetailTests(PartAPITestBase): 'category': 1, 'existing_image': 'does_not_exist.png', }, - expected_code=400 + expected_code=400, ) # Now, create a new part and assign the same image @@ -1734,7 +1568,7 @@ class PartDetailTests(PartAPITestBase): 'name': 'New part', 'description': 'New part description', 'category': 1, - 'existing_image': image_name.split(os.path.sep)[-1] + 'existing_image': image_name.split(os.path.sep)[-1], }, expected_code=201, ) @@ -1751,8 +1585,7 @@ class PartDetailTests(PartAPITestBase): # How many parts are 'on order' for this part? lines = order.models.PurchaseOrderLineItem.objects.filter( - part__part__pk=1, - order__status__in=PurchaseOrderStatusGroups.OPEN, + part__part__pk=1, order__status__in=PurchaseOrderStatusGroups.OPEN ) on_order = 0 @@ -1774,9 +1607,7 @@ class PartDetailTests(PartAPITestBase): """Check that path_detail can be requested against the serializer""" response = self.get( reverse('api-part-detail', kwargs={'pk': 1}), - { - 'path_detail': True, - }, + {'path_detail': True}, expected_code=200, ) @@ -1789,13 +1620,7 @@ class PartListTests(PartAPITestBase): def test_query_count(self): """Test that the query count is unchanged, independent of query results""" - queries = [ - {'limit': 1}, - {'limit': 10}, - {'limit': 50}, - {'category': 1}, - {}, - ] + queries = [{'limit': 1}, {'limit': 10}, {'limit': 50}, {'category': 1}, {}] url = reverse('api-part-list') @@ -1803,17 +1628,20 @@ class PartListTests(PartAPITestBase): parts = [] for ii in range(100): - parts.append(Part( - name=f"Extra part {ii}", - description="A new part which will appear via the API", - level=0, tree_id=0, - lft=0, rght=0, - )) + parts.append( + Part( + name=f"Extra part {ii}", + description="A new part which will appear via the API", + level=0, + tree_id=0, + lft=0, + rght=0, + ) + ) Part.objects.bulk_create(parts) for query in queries: - with CaptureQueriesContext(connection) as ctx: self.get(url, query, expected_code=200) @@ -1824,9 +1652,7 @@ class PartListTests(PartAPITestBase): for b in [False, True]: with CaptureQueriesContext(connection) as ctx: results = self.get( - reverse('api-part-list'), - {'category_detail': b}, - expected_code=200 + reverse('api-part-list'), {'category_detail': b}, expected_code=200 ) for result in results.data: @@ -1840,32 +1666,21 @@ class PartListTests(PartAPITestBase): class PartNotesTests(InvenTreeAPITestCase): """Tests for the 'notes' field (markdown field)""" - fixtures = [ - 'category', - 'part', - 'location', - 'company', - ] + fixtures = ['category', 'part', 'location', 'company'] - roles = [ - 'part.change', - 'part.add', - ] + roles = ['part.change', 'part.add'] def test_long_notes(self): """Test that very long notes field is rejected""" # Ensure that we cannot upload a very long piece of text url = reverse('api-part-detail', kwargs={'pk': 1}) - response = self.patch( - url, - { - 'notes': 'abcde' * 10001 - }, - expected_code=400 - ) + response = self.patch(url, {'notes': 'abcde' * 10001}, expected_code=400) - self.assertIn('Ensure this field has no more than 50000 characters', str(response.data['notes'])) + self.assertIn( + 'Ensure this field has no more than 50000 characters', + str(response.data['notes']), + ) def test_multiline_formatting(self): """Ensure that markdown formatting is retained""" @@ -1882,13 +1697,7 @@ class PartNotesTests(InvenTreeAPITestCase): """ - response = self.patch( - url, - { - 'notes': notes, - }, - expected_code=200 - ) + response = self.patch(url, {'notes': notes}, expected_code=200) # Ensure that newline chars have not been removed self.assertIn('\n', response.data['notes']) @@ -1900,15 +1709,9 @@ class PartNotesTests(InvenTreeAPITestCase): class PartPricingDetailTests(InvenTreeAPITestCase): """Tests for the part pricing API endpoint""" - fixtures = [ - 'category', - 'part', - 'location', - ] + fixtures = ['category', 'part', 'location'] - roles = [ - 'part.change', - ] + roles = ['part.change'] def url(self, pk): """Construct a pricing URL""" @@ -1916,10 +1719,7 @@ class PartPricingDetailTests(InvenTreeAPITestCase): def test_pricing_detail(self): """Test an empty pricing detail""" - response = self.get( - self.url(1), - expected_code=200 - ) + response = self.get(self.url(1), expected_code=200) # Check for expected fields expected_fields = [ @@ -1961,10 +1761,7 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): 'sales_order', ] - roles = [ - 'part.view', - 'part.change', - ] + roles = ['part.view', 'part.change'] @classmethod def setUpTestData(cls): @@ -1989,7 +1786,9 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): StockItem.objects.create(part=cls.part, quantity=300) # Now create another 400 units which are LOST - StockItem.objects.create(part=cls.part, quantity=400, status=StockStatus.LOST.value) + StockItem.objects.create( + part=cls.part, quantity=400, status=StockStatus.LOST.value + ) def get_part_data(self): """Helper function for retrieving part data""" @@ -2056,26 +1855,21 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): # Now, let's create a sales order, and allocate some stock so = order.models.SalesOrder.objects.create( - reference='001', - customer=Company.objects.get(pk=1), + reference='001', customer=Company.objects.get(pk=1) ) # We wish to send 50 units of "Bob" against this sales order line = order.models.SalesOrderLineItem.objects.create( - quantity=50, - order=so, - part=part, + quantity=50, order=so, part=part ) # Create a shipment against the order shipment_1 = order.models.SalesOrderShipment.objects.create( - order=so, - reference='001', + order=so, reference='001' ) shipment_2 = order.models.SalesOrderShipment.objects.create( - order=so, - reference='002', + order=so, reference='002' ) # Allocate stock items to this order, against multiple shipments @@ -2083,14 +1877,14 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): line=line, shipment=shipment_1, item=StockItem.objects.get(pk=1007), - quantity=17 + quantity=17, ) order.models.SalesOrderAllocation.objects.create( line=line, shipment=shipment_1, item=StockItem.objects.get(pk=1008), - quantity=18 + quantity=18, ) order.models.SalesOrderAllocation.objects.create( @@ -2129,16 +1923,11 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): bom_item = BomItem.objects.get(pk=6) - line = build.models.BuildLine.objects.get( - bom_item=bom_item, - build=bo, - ) + line = build.models.BuildLine.objects.get(bom_item=bom_item, build=bo) # Allocate multiple stock items against this build order build.models.BuildItem.objects.create( - build_line=line, - stock_item=StockItem.objects.get(pk=1000), - quantity=10, + build_line=line, stock_item=StockItem.objects.get(pk=1000), quantity=10 ) # Request data once more @@ -2157,9 +1946,7 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): # Allocate further stock against the build build.models.BuildItem.objects.create( - build_line=line, - stock_item=StockItem.objects.get(pk=1001), - quantity=10, + build_line=line, stock_item=StockItem.objects.get(pk=1001), quantity=10 ) # Request data once more @@ -2183,16 +1970,12 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): and should also use the 'pack_size' of the supplier part objects. """ supplier = Company.objects.create( - name='Paint Supplies', - description='A supplier of paints', - is_supplier=True + name='Paint Supplies', description='A supplier of paints', is_supplier=True ) # First, create some parts paint = PartCategory.objects.create( - parent=None, - name="Paint", - description="Paints and such", + parent=None, name="Paint", description="Paints and such" ) for color in ['Red', 'Green', 'Blue', 'Orange', 'Yellow']: @@ -2200,7 +1983,7 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): category=paint, units='litres', name=f"{color} Paint", - description=f"Paint which is {color} in color" + description=f"Paint which is {color} in color", ) # Create multiple supplier parts in different sizes @@ -2222,15 +2005,12 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): # Create multiple orders for _ii in range(5): - po = order.models.PurchaseOrder.objects.create( - supplier=supplier, - description='ordering some paint', + supplier=supplier, description='ordering some paint' ) # Order an assortment of items for sp in supplier_parts: - # Generate random quantity to order quantity = randint(10, 20) @@ -2239,19 +2019,12 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): # Add a line item item = order.models.PurchaseOrderLineItem.objects.create( - part=sp, - order=po, - quantity=quantity, - received=received, + part=sp, order=po, quantity=quantity, received=received ) # Now grab a list of parts from the API response = self.get( - reverse('api-part-list'), - { - 'category': paint.pk, - }, - expected_code=200, + reverse('api-part-list'), {'category': paint.pk}, expected_code=200 ) # Check that the correct number of items have been returned @@ -2283,20 +2056,9 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): class BomItemTest(InvenTreeAPITestCase): """Unit tests for the BomItem API.""" - fixtures = [ - 'category', - 'part', - 'location', - 'stock', - 'bom', - 'company', - ] + fixtures = ['category', 'part', 'location', 'stock', 'bom', 'company'] - roles = [ - 'part.add', - 'part.change', - 'part.delete', - ] + roles = ['part.add', 'part.change', 'part.delete'] def setUp(self): """Set up the test case""" @@ -2315,34 +2077,16 @@ class BomItemTest(InvenTreeAPITestCase): self.assertEqual(len(response.data), n) # Now, filter by part - response = self.get( - url, - data={ - 'part': 100, - }, - expected_code=200 - ) + response = self.get(url, data={'part': 100}, expected_code=200) # Filter by "validated" - response = self.get( - url, - data={ - 'validated': True, - }, - expected_code=200, - ) + response = self.get(url, data={'validated': True}, expected_code=200) # Should be zero validated results self.assertEqual(len(response.data), 0) # Now filter by "not validated" - response = self.get( - url, - data={ - 'validated': False, - }, - expected_code=200 - ) + response = self.get(url, data={'validated': False}, expected_code=200) # There should be at least one non-validated item self.assertTrue(len(response.data) > 0) @@ -2352,13 +2096,7 @@ class BomItemTest(InvenTreeAPITestCase): bom_item.validate_hash() - response = self.get( - url, - data={ - 'validated': True, - }, - expected_code=200, - ) + response = self.get(url, data={'validated': True}, expected_code=200) # Check that the expected response is returned self.assertEqual(len(response.data), 1) @@ -2366,7 +2104,6 @@ class BomItemTest(InvenTreeAPITestCase): # Each item in response should contain expected keys for el in response.data: - for key in ['available_stock', 'available_substitute_stock']: self.assertTrue(key in el) @@ -2379,37 +2116,19 @@ class BomItemTest(InvenTreeAPITestCase): self.assertEqual(len(response.data), 6) # Limit the results with a search term - response = self.get( - url, - { - 'search': '0805', - }, - expected_code=200, - ) + response = self.get(url, {'search': '0805'}, expected_code=200) self.assertEqual(len(response.data), 3) # Search by 'reference' field for q in ['ABCDE', 'LMNOP', 'VWXYZ']: - response = self.get( - url, - { - 'search': q, - }, - expected_code=200 - ) + response = self.get(url, {'search': q}, expected_code=200) self.assertEqual(len(response.data), 1) self.assertEqual(response.data[0]['reference'], q) # Search by nonsense data - response = self.get( - url, - { - 'search': 'xxxxxxxxxxxxxxxxx', - }, - expected_code=200 - ) + response = self.get(url, {'search': 'xxxxxxxxxxxxxxxxx'}, expected_code=200) self.assertEqual(len(response.data), 0) @@ -2418,10 +2137,7 @@ class BomItemTest(InvenTreeAPITestCase): url = reverse('api-bom-list') # Order by increasing quantity - response = self.get( - f"{url}?ordering=+quantity", - expected_code=200 - ) + response = self.get(f"{url}?ordering=+quantity", expected_code=200) self.assertEqual(len(response.data), 6) @@ -2431,34 +2147,21 @@ class BomItemTest(InvenTreeAPITestCase): self.assertTrue(q1 < q2) # Order by decreasing quantity - response = self.get( - f"{url}?ordering=-quantity", - expected_code=200, - ) + response = self.get(f"{url}?ordering=-quantity", expected_code=200) self.assertEqual(q1, response.data[-1]['quantity']) self.assertEqual(q2, response.data[0]['quantity']) # Now test ordering by 'sub_part' (which is actually 'sub_part__name') response = self.get( - url, - { - 'ordering': 'sub_part', - 'sub_part_detail': True, - }, - expected_code=200, + url, {'ordering': 'sub_part', 'sub_part_detail': True}, expected_code=200 ) n1 = response.data[0]['sub_part_detail']['name'] n2 = response.data[-1]['sub_part_detail']['name'] response = self.get( - url, - { - 'ordering': '-sub_part', - 'sub_part_detail': True, - }, - expected_code=200, + url, {'ordering': '-sub_part', 'sub_part_detail': True}, expected_code=200 ) self.assertEqual(n1, response.data[-1]['sub_part_detail']['name']) @@ -2507,11 +2210,7 @@ class BomItemTest(InvenTreeAPITestCase): """Test that we can create a new BomItem via the API.""" url = reverse('api-bom-list') - data = { - 'part': 100, - 'sub_part': 4, - 'quantity': 777, - } + data = {'part': 100, 'sub_part': 4, 'quantity': 777} self.post(url, data, expected_code=201) @@ -2537,13 +2236,7 @@ class BomItemTest(InvenTreeAPITestCase): sub_part.save() # How many stock items are initially available for this part? - response = self.get( - stock_url, - { - 'bom_item': bom_item.pk, - }, - expected_code=200 - ) + response = self.get(stock_url, {'bom_item': bom_item.pk}, expected_code=200) n_items = len(response.data) self.assertEqual(n_items, 2) @@ -2552,13 +2245,12 @@ class BomItemTest(InvenTreeAPITestCase): # Now we will create some variant parts and stock for ii in range(5): - # Create a variant part! variant = Part.objects.create( name=f"Variant_{ii}", description="A variant part, with a description", component=True, - variant_of=sub_part + variant_of=sub_part, ) variant.save() @@ -2567,23 +2259,13 @@ class BomItemTest(InvenTreeAPITestCase): # Create some stock items for this new part for _ in range(ii): - StockItem.objects.create( - part=variant, - location=loc, - quantity=100 - ) + StockItem.objects.create(part=variant, location=loc, quantity=100) # Keep track of running total n_items += ii # Now, there should be more stock items available! - response = self.get( - stock_url, - { - 'bom_item': bom_item.pk, - }, - expected_code=200 - ) + response = self.get(stock_url, {'bom_item': bom_item.pk}, expected_code=200) self.assertEqual(len(response.data), n_items) @@ -2592,13 +2274,7 @@ class BomItemTest(InvenTreeAPITestCase): bom_item.save() # There should now only be 2 stock items available again - response = self.get( - stock_url, - { - 'bom_item': bom_item.pk, - }, - expected_code=200 - ) + response = self.get(stock_url, {'bom_item': bom_item.pk}, expected_code=200) self.assertEqual(len(response.data), 2) @@ -2619,13 +2295,7 @@ class BomItemTest(InvenTreeAPITestCase): bom_item = BomItem.objects.get(pk=1) # Filter stock items which can be assigned against this stock item - response = self.get( - stock_url, - { - "bom_item": bom_item.pk, - }, - expected_code=200 - ) + response = self.get(stock_url, {"bom_item": bom_item.pk}, expected_code=200) n_items = len(response.data) @@ -2638,34 +2308,21 @@ class BomItemTest(InvenTreeAPITestCase): description="A substitute part", component=True, is_template=False, - assembly=False + assembly=False, ) # Create a new StockItem for this Part - StockItem.objects.create( - part=sub_part, - quantity=1000, - location=loc, - ) + StockItem.objects.create(part=sub_part, quantity=1000, location=loc) # Now, create an "alternative" for the BOM Item - BomItemSubstitute.objects.create( - bom_item=bom_item, - part=sub_part - ) + BomItemSubstitute.objects.create(bom_item=bom_item, part=sub_part) # We should be able to filter the API list to just return this new part response = self.get(url, data={'part': sub_part.pk}, expected_code=200) self.assertEqual(len(response.data), 1) # We should also have more stock available to allocate against this BOM item! - response = self.get( - stock_url, - { - "bom_item": bom_item.pk, - }, - expected_code=200 - ) + response = self.get(stock_url, {"bom_item": bom_item.pk}, expected_code=200) self.assertEqual(len(response.data), n_items + ii + 1) @@ -2676,7 +2333,7 @@ class BomItemTest(InvenTreeAPITestCase): # The BomItem detail endpoint should now also reflect the substitute data data = self.get( reverse('api-bom-item-detail', kwargs={'pk': bom_item.pk}), - expected_code=200 + expected_code=200, ).data # 5 substitute parts @@ -2701,7 +2358,7 @@ class BomItemTest(InvenTreeAPITestCase): name=f"Assy_{i}", description="An assembly made of other parts", active=True, - assembly=True + assembly=True, ) assemblies.append(assy) @@ -2710,31 +2367,20 @@ class BomItemTest(InvenTreeAPITestCase): # Create some sub-components for i in range(5): - cmp = Part.objects.create( name=f"Component_{i}", description="A sub component", active=True, - component=True + component=True, ) for j in range(i): # Create a BOM item - BomItem.objects.create( - quantity=10, - part=assemblies[j], - sub_part=cmp, - ) + BomItem.objects.create(quantity=10, part=assemblies[j], sub_part=cmp) components.append(cmp) - response = self.get( - url, - { - 'uses': cmp.pk, - }, - expected_code=200, - ) + response = self.get(url, {'uses': cmp.pk}, expected_code=200) self.assertEqual(len(response.data), i) @@ -2763,10 +2409,7 @@ class BomItemTest(InvenTreeAPITestCase): ) # Create a stock item - StockItem.objects.create( - part=vp, - quantity=100, - ) + StockItem.objects.create(part=vp, quantity=100) # There should now be variant stock available response = self.get('/api/bom/1/', {}, expected_code=200) @@ -2777,45 +2420,26 @@ class BomItemTest(InvenTreeAPITestCase): class PartAttachmentTest(InvenTreeAPITestCase): """Unit tests for the PartAttachment API endpoint""" - fixtures = [ - 'category', - 'part', - 'location', - ] + fixtures = ['category', 'part', 'location'] def test_add_attachment(self): """Test that we can create a new PartAttachment via the API""" url = reverse('api-part-attachment-list') # Upload without permission - response = self.post( - url, - {}, - expected_code=403, - ) + response = self.post(url, {}, expected_code=403) # Add required permission self.assignRole('part.add') # Upload without specifying part (will fail) - response = self.post( - url, - { - 'comment': 'Hello world', - }, - expected_code=400 - ) + response = self.post(url, {'comment': 'Hello world'}, expected_code=400) self.assertIn('This field is required', str(response.data['part'])) # Upload without file OR link (will fail) response = self.post( - url, - { - 'part': 1, - 'comment': 'Hello world', - }, - expected_code=400 + url, {'part': 1, 'comment': 'Hello world'}, expected_code=400 ) self.assertIn('Missing file', str(response.data['attachment'])) @@ -2823,12 +2447,7 @@ class PartAttachmentTest(InvenTreeAPITestCase): # Upload an invalid link (will fail) response = self.post( - url, - { - 'part': 1, - 'link': 'not-a-link.py', - }, - expected_code=400 + url, {'part': 1, 'link': 'not-a-link.py'}, expected_code=400 ) self.assertIn('Enter a valid URL', str(response.data['link'])) @@ -2837,13 +2456,7 @@ class PartAttachmentTest(InvenTreeAPITestCase): # Upload a valid link (will pass) response = self.post( - url, - { - 'part': 1, - 'link': link, - 'comment': 'Hello world', - }, - expected_code=201 + url, {'part': 1, 'link': link, 'comment': 'Hello world'}, expected_code=201 ) data = response.data @@ -2889,29 +2502,17 @@ class PartInternalPriceBreakTest(InvenTreeAPITestCase): (1.5, 90.999999999), (1.756, 89), (2, 86), - (25, 80) + (25, 80), ] for q, p in breaks: data = self.post( - url, - { - 'part': 1, - 'quantity': q, - 'price': p, - }, - expected_code=201 + url, {'part': 1, 'quantity': q, 'price': p}, expected_code=201 ).data self.assertEqual(data['part'], 1) - self.assertEqual( - round(Decimal(data['quantity']), 4), - round(Decimal(q), 4) - ) - self.assertEqual( - round(Decimal(data['price']), 4), - round(Decimal(p), 4) - ) + self.assertEqual(round(Decimal(data['quantity']), 4), round(Decimal(q), 4)) + self.assertEqual(round(Decimal(data['price']), 4), round(Decimal(p), 4)) # Now, ensure that we can delete the Part via the API # In particular this test checks that there are no circular post_delete relationships @@ -2935,12 +2536,7 @@ class PartStocktakeTest(InvenTreeAPITestCase): superuser = False is_staff = False - fixtures = [ - 'category', - 'part', - 'location', - 'stock', - ] + fixtures = ['category', 'part', 'location', 'stock'] def test_list_endpoint(self): """Test the list endpoint for the stocktake data""" @@ -2957,19 +2553,13 @@ class PartStocktakeTest(InvenTreeAPITestCase): # Iterate over (up to) 5 parts in the database for p in Part.objects.all()[:5]: - # Create some entries to_create = [] n = p.pk % 10 for idx in range(n): - to_create.append( - PartStocktake( - part=p, - quantity=(idx + 1) * 100, - ) - ) + to_create.append(PartStocktake(part=p, quantity=(idx + 1) * 100)) total += 1 @@ -2977,13 +2567,7 @@ class PartStocktakeTest(InvenTreeAPITestCase): PartStocktake.objects.bulk_create(to_create) # Query list endpoint - response = self.get( - url, - { - 'part': p.pk, - }, - expected_code=200, - ) + response = self.get(url, {'part': p.pk}, expected_code=200) # Check that the expected number of PartStocktake instances has been created self.assertEqual(len(response.data), n) @@ -3001,7 +2585,6 @@ class PartStocktakeTest(InvenTreeAPITestCase): self.assignRole('stocktake.view') for p in Part.objects.all(): - # Initially no stocktake information available self.assertIsNone(p.latest_stocktake) @@ -3010,11 +2593,7 @@ class PartStocktakeTest(InvenTreeAPITestCase): self.post( url, - { - 'part': p.pk, - 'quantity': quantity, - 'note': note, - }, + {'part': p.pk, 'quantity': quantity, 'note': note}, expected_code=201, ) @@ -3042,25 +2621,13 @@ class PartStocktakeTest(InvenTreeAPITestCase): self.get(url, expected_code=200) # Try to edit data - self.patch( - url, - { - 'note': 'Another edit', - }, - expected_code=403 - ) + self.patch(url, {'note': 'Another edit'}, expected_code=403) # Assign 'edit' role permission self.assignRole('stocktake.change') # Try again - self.patch( - url, - { - 'note': 'Editing note field again', - }, - expected_code=200, - ) + self.patch(url, {'note': 'Editing note field again'}, expected_code=200) # Try to delete self.delete(url, expected_code=403) @@ -3090,7 +2657,9 @@ class PartStocktakeTest(InvenTreeAPITestCase): self.assertEqual(len(response.data), n) # Stocktake report should be available via the API, also - response = self.get(reverse('api-part-stocktake-report-list'), expected_code=200) + response = self.get( + reverse('api-part-stocktake-report-list'), expected_code=200 + ) self.assertEqual(len(response.data), 1) @@ -3137,10 +2706,7 @@ class PartMetadataAPITest(InvenTreeAPITestCase): 'stock', ] - roles = [ - 'part.change', - 'part_category.change', - ] + roles = ['part.change', 'part_category.change'] def setUp(self): """Setup unit tets""" @@ -3163,17 +2729,15 @@ class PartMetadataAPITest(InvenTreeAPITestCase): self.patch( url, - { - 'metadata': { - f'abc-{numstr}': f'xyz-{apikey}-{numstr}', - } - }, - expected_code=200 + {'metadata': {f'abc-{numstr}': f'xyz-{apikey}-{numstr}'}}, + expected_code=200, ) # Refresh modeldata.refresh_from_db() - self.assertEqual(modeldata.get_metadata(f'abc-{numstr}'), f'xyz-{apikey}-{numstr}') + self.assertEqual( + modeldata.get_metadata(f'abc-{numstr}'), f'xyz-{apikey}-{numstr}' + ) def test_metadata(self): """Test all endpoints""" @@ -3196,9 +2760,7 @@ class PartSchedulingTest(PartAPITestBase): def test_get_schedule(self): """Test that the scheduling endpoint returns OK""" - part_ids = [ - 1, 3, 100, 101, - ] + part_ids = [1, 3, 100, 101] for pk in part_ids: url = reverse('api-part-scheduling', kwargs={'pk': pk}) diff --git a/InvenTree/part/test_bom_export.py b/InvenTree/part/test_bom_export.py index 1027e0c89e..46fcd199fe 100644 --- a/InvenTree/part/test_bom_export.py +++ b/InvenTree/part/test_bom_export.py @@ -11,12 +11,7 @@ from InvenTree.unit_test import InvenTreeTestCase class BomExportTest(InvenTreeTestCase): """Class for performing unit testing of BOM export functionality""" - fixtures = [ - 'category', - 'part', - 'location', - 'bom', - ] + fixtures = ['category', 'part', 'location', 'bom'] roles = 'all' @@ -37,7 +32,7 @@ class BomExportTest(InvenTreeTestCase): self.assertEqual(response.status_code, 200) self.assertEqual( response.headers['Content-Disposition'], - 'attachment; filename="InvenTree_BOM_Template.xls"' + 'attachment; filename="InvenTree_BOM_Template.xls"', ) # Return a simple CSV template @@ -45,7 +40,7 @@ class BomExportTest(InvenTreeTestCase): self.assertEqual(response.status_code, 200) self.assertEqual( response.headers['Content-Disposition'], - 'attachment; filename="InvenTree_BOM_Template.csv"' + 'attachment; filename="InvenTree_BOM_Template.csv"', ) filename = '_tmp.csv' diff --git a/InvenTree/part/test_bom_import.py b/InvenTree/part/test_bom_import.py index ae24418499..0a4a85fc21 100644 --- a/InvenTree/part/test_bom_import.py +++ b/InvenTree/part/test_bom_import.py @@ -12,10 +12,7 @@ from part.models import Part class BomUploadTest(InvenTreeAPITestCase): """Test BOM file upload API endpoint.""" - roles = [ - 'part.add', - 'part.change', - ] + roles = ['part.add', 'part.change'] @classmethod def setUpTestData(cls): @@ -41,29 +38,32 @@ class BomUploadTest(InvenTreeAPITestCase): description="A subcomponent that can be used in a BOM", component=True, assembly=False, - lft=0, rght=0, - level=0, tree_id=0, + lft=0, + rght=0, + level=0, + tree_id=0, ) ) Part.objects.bulk_create(parts) - def post_bom(self, filename, file_data, clear_existing=None, expected_code=None, content_type='text/plain'): + def post_bom( + self, + filename, + file_data, + clear_existing=None, + expected_code=None, + content_type='text/plain', + ): """Helper function for submitting a BOM file""" - bom_file = SimpleUploadedFile( - filename, - file_data, - content_type=content_type, - ) + bom_file = SimpleUploadedFile(filename, file_data, content_type=content_type) if clear_existing is None: clear_existing = False response = self.post( reverse('api-bom-import-upload'), - data={ - 'data_file': bom_file, - }, + data={'data_file': bom_file}, expected_code=expected_code, format='multipart', ) @@ -73,30 +73,20 @@ class BomUploadTest(InvenTreeAPITestCase): def test_missing_file(self): """POST without a file.""" response = self.post( - reverse('api-bom-import-upload'), - data={}, - expected_code=400 + reverse('api-bom-import-upload'), data={}, expected_code=400 ) self.assertIn('No file was submitted', str(response.data['data_file'])) def test_unsupported_file(self): """POST with an unsupported file type.""" - response = self.post_bom( - 'sample.txt', - b'hello world', - expected_code=400, - ) + response = self.post_bom('sample.txt', b'hello world', expected_code=400) self.assertIn('Unsupported file type', str(response.data['data_file'])) def test_broken_file(self): """Test upload with broken (corrupted) files.""" - response = self.post_bom( - 'sample.csv', - b'', - expected_code=400, - ) + response = self.post_bom('sample.csv', b'', expected_code=400) self.assertIn('The submitted file is empty', str(response.data['data_file'])) @@ -107,16 +97,15 @@ class BomUploadTest(InvenTreeAPITestCase): content_type='application/xls', ) - self.assertIn('Unsupported format, or corrupt file', str(response.data['data_file'])) + self.assertIn( + 'Unsupported format, or corrupt file', str(response.data['data_file']) + ) def test_missing_rows(self): """Test upload of an invalid file (without data rows)""" dataset = tablib.Dataset() - dataset.headers = [ - 'apple', - 'banana', - ] + dataset.headers = ['apple', 'banana'] response = self.post_bom( 'test.csv', @@ -129,10 +118,7 @@ class BomUploadTest(InvenTreeAPITestCase): # Try again, with an .xlsx file response = self.post_bom( - 'bom.xlsx', - dataset.xlsx, - content_type='application/xlsx', - expected_code=400, + 'bom.xlsx', dataset.xlsx, content_type='application/xlsx', expected_code=400 ) self.assertIn('No data rows found in file', str(response.data)) @@ -141,50 +127,28 @@ class BomUploadTest(InvenTreeAPITestCase): """Upload extracted data, but with missing columns.""" url = reverse('api-bom-import-extract') - rows = [ - ['1', 'test'], - ['2', 'test'], - ] + rows = [['1', 'test'], ['2', 'test']] # Post without columns - response = self.post( - url, - {}, - expected_code=400, - ) + response = self.post(url, {}, expected_code=400) self.assertIn('This field is required', str(response.data['rows'])) self.assertIn('This field is required', str(response.data['columns'])) response = self.post( - url, - { - 'rows': rows, - 'columns': ['part', 'reference'], - }, - expected_code=400 + url, {'rows': rows, 'columns': ['part', 'reference']}, expected_code=400 ) self.assertIn("Missing required column: 'quantity'", str(response.data)) response = self.post( - url, - { - 'rows': rows, - 'columns': ['quantity', 'reference'], - }, - expected_code=400, + url, {'rows': rows, 'columns': ['quantity', 'reference']}, expected_code=400 ) self.assertIn('No part column specified', str(response.data)) self.post( - url, - { - 'rows': rows, - 'columns': ['quantity', 'part'], - }, - expected_code=201, + url, {'rows': rows, 'columns': ['quantity', 'part']}, expected_code=201 ) def test_invalid_data(self): @@ -197,7 +161,6 @@ class BomUploadTest(InvenTreeAPITestCase): components = Part.objects.filter(component=True) for idx, cmp in enumerate(components): - if idx == 5: cmp.component = False cmp.save() @@ -206,13 +169,7 @@ class BomUploadTest(InvenTreeAPITestCase): url = reverse('api-bom-import-extract') - response = self.post( - url, - { - 'columns': dataset.headers, - 'rows': list(dataset), - }, - ) + response = self.post(url, {'columns': dataset.headers, 'rows': list(dataset)}) rows = response.data['rows'] @@ -227,8 +184,12 @@ class BomUploadTest(InvenTreeAPITestCase): data['part'] = cmp.pk # Check some specific error messages - self.assertEqual(rows[0]['data']['errors']['quantity'], 'Quantity must be greater than zero') - self.assertEqual(rows[5]['data']['errors']['part'], 'Part is not designated as a component') + self.assertEqual( + rows[0]['data']['errors']['quantity'], 'Quantity must be greater than zero' + ) + self.assertEqual( + rows[5]['data']['errors']['part'], 'Part is not designated as a component' + ) def test_part_guess(self): """Test part 'guessing' when PK values are not supplied.""" @@ -240,20 +201,12 @@ class BomUploadTest(InvenTreeAPITestCase): components = Part.objects.filter(component=True) for component in components: - dataset.append([ - component.name, - 10, - ]) + dataset.append([component.name, 10]) url = reverse('api-bom-import-extract') response = self.post( - url, - { - 'columns': dataset.headers, - 'rows': list(dataset), - }, - expected_code=201, + url, {'columns': dataset.headers, 'rows': list(dataset)}, expected_code=201 ) rows = response.data['rows'] @@ -269,18 +222,10 @@ class BomUploadTest(InvenTreeAPITestCase): dataset.headers = ['part_ipn', 'quantity'] for component in components: - dataset.append([ - component.IPN, - 10, - ]) + dataset.append([component.IPN, 10]) response = self.post( - url, - { - 'columns': dataset.headers, - 'rows': list(dataset), - }, - expected_code=201, + url, {'columns': dataset.headers, 'rows': list(dataset)}, expected_code=201 ) rows = response.data['rows'] @@ -301,19 +246,10 @@ class BomUploadTest(InvenTreeAPITestCase): components = Part.objects.filter(component=True) for idx, cmp in enumerate(components): - dataset.append([ - idx % 3, - cmp.pk, - 2, - ]) + dataset.append([idx % 3, cmp.pk, 2]) response = self.post( - url, - { - 'rows': list(dataset), - 'columns': dataset.headers, - }, - expected_code=201, + url, {'rows': list(dataset), 'columns': dataset.headers}, expected_code=201 ) rows = response.data['rows'] diff --git a/InvenTree/part/test_bom_item.py b/InvenTree/part/test_bom_item.py index fecd0abbb1..543c576357 100644 --- a/InvenTree/part/test_bom_item.py +++ b/InvenTree/part/test_bom_item.py @@ -1,4 +1,3 @@ - """Unit tests for the BomItem model""" from decimal import Decimal @@ -70,7 +69,9 @@ class BomItemTest(TestCase): def test_integer_quantity(self): """Test integer validation for BomItem.""" - p = Part.objects.create(name="test", description="part description", component=True, trackable=True) + p = Part.objects.create( + name="test", description="part description", component=True, trackable=True + ) # Creation of a BOMItem with a non-integer quantity of a trackable Part should fail with self.assertRaises(django_exceptions.ValidationError): @@ -134,22 +135,19 @@ class BomItemTest(TestCase): self.bob.get_price(1) self.assertEqual( self.bob.get_bom_price_range(1, internal=True), - (Decimal(29.5), Decimal(89.5)) + (Decimal(29.5), Decimal(89.5)), ) # remove internal price for R_2K2_0805 self.r1.internal_price_breaks.delete() self.assertEqual( self.bob.get_bom_price_range(1, internal=True), - (Decimal(27.5), Decimal(87.5)) + (Decimal(27.5), Decimal(87.5)), ) def test_substitutes(self): """Tests for BOM item substitutes.""" # We will make some substitute parts for the "orphan" part - bom_item = BomItem.objects.get( - part=self.bob, - sub_part=self.orphan - ) + bom_item = BomItem.objects.get(part=self.bob, sub_part=self.orphan) # No substitute parts available self.assertEqual(bom_item.substitutes.count(), 0) @@ -157,7 +155,6 @@ class BomItemTest(TestCase): subs = [] for ii in range(5): - # Create a new part sub_part = Part.objects.create( name=f"Orphan {ii}", @@ -170,28 +167,19 @@ class BomItemTest(TestCase): subs.append(sub_part) # Link it as a substitute part - BomItemSubstitute.objects.create( - bom_item=bom_item, - part=sub_part - ) + BomItemSubstitute.objects.create(bom_item=bom_item, part=sub_part) # Try to link it again (this should fail as it is a duplicate substitute) with self.assertRaises(django_exceptions.ValidationError): with transaction.atomic(): - BomItemSubstitute.objects.create( - bom_item=bom_item, - part=sub_part - ) + BomItemSubstitute.objects.create(bom_item=bom_item, part=sub_part) # There should be now 5 substitute parts available self.assertEqual(bom_item.substitutes.count(), 5) # Try to create a substitute which points to the same sub-part (should fail) with self.assertRaises(django_exceptions.ValidationError): - BomItemSubstitute.objects.create( - bom_item=bom_item, - part=self.orphan, - ) + BomItemSubstitute.objects.create(bom_item=bom_item, part=self.orphan) # Remove one substitute part bom_item.substitutes.last().delete() @@ -207,45 +195,42 @@ class BomItemTest(TestCase): def test_consumable(self): """Tests for the 'consumable' BomItem field""" # Create an assembly part - assembly = Part.objects.create(name="An assembly", description="Made with parts", assembly=True) + assembly = Part.objects.create( + name="An assembly", description="Made with parts", assembly=True + ) # No BOM information initially self.assertEqual(assembly.can_build, 0) # Create some component items - c1 = Part.objects.create(name="C1", description="Part C1 - this is just the part description") - c2 = Part.objects.create(name="C2", description="Part C2 - this is just the part description") - c3 = Part.objects.create(name="C3", description="Part C3 - this is just the part description") - c4 = Part.objects.create(name="C4", description="Part C4 - this is just the part description") + c1 = Part.objects.create( + name="C1", description="Part C1 - this is just the part description" + ) + c2 = Part.objects.create( + name="C2", description="Part C2 - this is just the part description" + ) + c3 = Part.objects.create( + name="C3", description="Part C3 - this is just the part description" + ) + c4 = Part.objects.create( + name="C4", description="Part C4 - this is just the part description" + ) for p in [c1, c2, c3, c4]: # Ensure we have stock stock.models.StockItem.objects.create(part=p, quantity=1000) # Create some BOM items - BomItem.objects.create( - part=assembly, - sub_part=c1, - quantity=10 - ) + BomItem.objects.create(part=assembly, sub_part=c1, quantity=10) self.assertEqual(assembly.can_build, 100) - BomItem.objects.create( - part=assembly, - sub_part=c2, - quantity=50, - consumable=True - ) + BomItem.objects.create(part=assembly, sub_part=c2, quantity=50, consumable=True) # A 'consumable' BomItem does not alter the can_build calculation self.assertEqual(assembly.can_build, 100) - BomItem.objects.create( - part=assembly, - sub_part=c3, - quantity=50, - ) + BomItem.objects.create(part=assembly, sub_part=c3, quantity=50) self.assertEqual(assembly.can_build, 20) @@ -271,16 +256,28 @@ class BomItemTest(TestCase): # First test: A BOM item which points to itself with self.assertRaises(django_exceptions.ValidationError): - BomItem.objects.create( - part=self.bob, - sub_part=self.bob, - quantity=1 - ) + BomItem.objects.create(part=self.bob, sub_part=self.bob, quantity=1) # Second test: A recursive BOM - part_a = Part.objects.create(name='Part A', description="A part which is called A", assembly=True, is_template=True, component=True) - part_b = Part.objects.create(name='Part B', description="A part which is called B", assembly=True, component=True) - part_c = Part.objects.create(name='Part C', description="A part which is called C", assembly=True, component=True) + part_a = Part.objects.create( + name='Part A', + description="A part which is called A", + assembly=True, + is_template=True, + component=True, + ) + part_b = Part.objects.create( + name='Part B', + description="A part which is called B", + assembly=True, + component=True, + ) + part_c = Part.objects.create( + name='Part C', + description="A part which is called C", + assembly=True, + component=True, + ) BomItem.objects.create(part=part_a, sub_part=part_b, quantity=10) BomItem.objects.create(part=part_b, sub_part=part_c, quantity=10) @@ -292,7 +289,13 @@ class BomItemTest(TestCase): BomItem.objects.create(part=part_c, sub_part=part_b, quantity=10) # Third test: A recursive BOM with a variant part - part_v = Part.objects.create(name='Part V', description='A part which is called V', variant_of=part_a, assembly=True, component=True) + part_v = Part.objects.create( + name='Part V', + description='A part which is called V', + variant_of=part_a, + assembly=True, + component=True, + ) with self.assertRaises(django_exceptions.ValidationError): BomItem.objects.create(part=part_a, sub_part=part_v, quantity=10) diff --git a/InvenTree/part/test_category.py b/InvenTree/part/test_category.py index 714c192449..be29cae943 100644 --- a/InvenTree/part/test_category.py +++ b/InvenTree/part/test_category.py @@ -12,12 +12,8 @@ class CategoryTest(TestCase): Loads the following test fixtures: - category.yaml """ - fixtures = [ - 'category', - 'part', - 'location', - 'params', - ] + + fixtures = ['category', 'part', 'location', 'params'] @classmethod def setUpTestData(cls): @@ -72,13 +68,15 @@ class CategoryTest(TestCase): self.transceivers.save() self.assertEqual(str(self.resistors), 'Electronics/Resistors - Resistors') - self.assertEqual(str(self.transceivers.pathstring), 'Electronics/IC/Transceivers') + self.assertEqual( + str(self.transceivers.pathstring), 'Electronics/IC/Transceivers' + ) # Create a new subcategory subcat = PartCategory.objects.create( name='Subcategory', description='My little sub category', - parent=self.transceivers + parent=self.transceivers, ) # Pathstring should have been updated correctly @@ -99,9 +97,7 @@ class CategoryTest(TestCase): # Construct a very long pathstring and ensure it gets updated correctly cat = PartCategory.objects.create( - name='Cat', - description='A long running category', - parent=None + name='Cat', description='A long running category', parent=None ) parent = cat @@ -110,9 +106,7 @@ class CategoryTest(TestCase): letter = chr(ord('A') + idx) child = PartCategory.objects.create( - name=letter * 10, - description=f"Subcategory {letter}", - parent=parent + name=letter * 10, description=f"Subcategory {letter}", parent=parent ) parent = child @@ -120,7 +114,7 @@ class CategoryTest(TestCase): self.assertTrue(len(child.path), 26) self.assertEqual( child.pathstring, - "Cat/AAAAAAAAAA/BBBBBBBBBB/CCCCCCCCCC/DDDDDDDDDD/EEEEEEEEEE/FFFFFFFFFF/GGGGGGGGGG/HHHHHHHHHH/IIIIIIIIII/JJJJJJJJJJ/KKKKKKKKK...OO/PPPPPPPPPP/QQQQQQQQQQ/RRRRRRRRRR/SSSSSSSSSS/TTTTTTTTTT/UUUUUUUUUU/VVVVVVVVVV/WWWWWWWWWW/XXXXXXXXXX/YYYYYYYYYY/ZZZZZZZZZZ" + "Cat/AAAAAAAAAA/BBBBBBBBBB/CCCCCCCCCC/DDDDDDDDDD/EEEEEEEEEE/FFFFFFFFFF/GGGGGGGGGG/HHHHHHHHHH/IIIIIIIIII/JJJJJJJJJJ/KKKKKKKKK...OO/PPPPPPPPPP/QQQQQQQQQQ/RRRRRRRRRR/SSSSSSSSSS/TTTTTTTTTT/UUUUUUUUUU/VVVVVVVVVV/WWWWWWWWWW/XXXXXXXXXX/YYYYYYYYYY/ZZZZZZZZZZ", ) self.assertTrue(len(child.pathstring) <= 250) @@ -168,7 +162,9 @@ class CategoryTest(TestCase): self.assertIsInstance(parameter.template, PartParameterTemplate) # Test number of unique parameters - self.assertEqual(len(self.fasteners.get_unique_parameters(prefetch=fasteners)), 1) + self.assertEqual( + len(self.fasteners.get_unique_parameters(prefetch=fasteners)), 1 + ) # Test number of parameters found for each part parts_parameters = self.fasteners.get_parts_parameters(prefetch=fasteners) part_infos = ['pk', 'name', 'description'] @@ -202,7 +198,9 @@ class CategoryTest(TestCase): """Test traversal for default locations.""" self.assertIsNotNone(self.fasteners.default_location) self.fasteners.default_location.save() - self.assertEqual(str(self.fasteners.default_location), 'Office/Drawer_1 - In my desk') + self.assertEqual( + str(self.fasteners.default_location), 'Office/Drawer_1 - In my desk' + ) # Any part under electronics should default to 'Home' r1 = Part.objects.get(name='R_2K2_0805') @@ -229,10 +227,7 @@ class CategoryTest(TestCase): PartCategory.objects.rebuild() # First, create a structured tree of part categories - A = PartCategory.objects.create( - name='A', - description='Top level category', - ) + A = PartCategory.objects.create(name='A', description='Top level category') B1 = PartCategory.objects.create(name='B1', parent=A) B2 = PartCategory.objects.create(name='B2', parent=A) @@ -280,9 +275,7 @@ class CategoryTest(TestCase): for i in range(10): Part.objects.create( - name=f'Part {i}', - description='A test part', - category=B3, + name=f'Part {i}', description='A test part', category=B3 ) self.assertEqual(Part.objects.filter(category=B3).count(), 10) @@ -395,9 +388,7 @@ class CategoryTest(TestCase): # Create some sample parts under D32 for ii in range(10): Part.objects.create( - name=f'Part D32 {ii}', - description='A test part', - category=D32, + name=f'Part D32 {ii}', description='A test part', category=D32 ) self.assertEqual(Part.objects.filter(category=D32).count(), 10) diff --git a/InvenTree/part/test_migrations.py b/InvenTree/part/test_migrations.py index 59c1a650c4..3bfb3087b6 100644 --- a/InvenTree/part/test_migrations.py +++ b/InvenTree/part/test_migrations.py @@ -92,17 +92,17 @@ class TestParameterMigrations(MigratorTestCase): """Create some parts, and templates with parameters""" Part = self.old_state.apps.get_model('part', 'part') PartParameter = self.old_state.apps.get_model('part', 'partparameter') - PartParameterTemlate = self.old_state.apps.get_model('part', 'partparametertemplate') + PartParameterTemlate = self.old_state.apps.get_model( + 'part', 'partparametertemplate' + ) # Create some parts a = Part.objects.create( - name='Part A', description='My part A', - level=0, lft=0, rght=0, tree_id=0, + name='Part A', description='My part A', level=0, lft=0, rght=0, tree_id=0 ) b = Part.objects.create( - name='Part B', description='My part B', - level=0, lft=0, rght=0, tree_id=0, + name='Part B', description='My part B', level=0, lft=0, rght=0, tree_id=0 ) # Create some templates @@ -111,7 +111,7 @@ class TestParameterMigrations(MigratorTestCase): # Create some parameter values PartParameter.objects.create(part=a, template=t1, data='1.0') - PartParameter.objects.create(part=a, template=t2, data='-2mA',) + PartParameter.objects.create(part=a, template=t2, data='-2mA') PartParameter.objects.create(part=b, template=t1, data='1/10 inch') PartParameter.objects.create(part=b, template=t2, data='abc') @@ -120,7 +120,9 @@ class TestParameterMigrations(MigratorTestCase): """Test that the template units and values have been updated correctly""" Part = self.new_state.apps.get_model('part', 'part') PartParameter = self.new_state.apps.get_model('part', 'partparameter') - PartParameterTemlate = self.new_state.apps.get_model('part', 'partparametertemplate') + PartParameterTemlate = self.new_state.apps.get_model( + 'part', 'partparametertemplate' + ) # Extract the parts a = Part.objects.get(name='Part A') @@ -166,8 +168,13 @@ class PartUnitsMigrationTest(MigratorTestCase): for idx, unit in enumerate(units): Part.objects.create( - name=f'Part {idx + 1}', description=f'My part at index {idx}', units=unit, - level=0, lft=0, rght=0, tree_id=0, + name=f'Part {idx + 1}', + description=f'My part at index {idx}', + units=unit, + level=0, + lft=0, + rght=0, + tree_id=0, ) def test_units_migration(self): @@ -196,10 +203,14 @@ class TestPartParameterTemplateMigration(MigratorTestCase): def prepare(self): """Prepare some parts with units""" - PartParameterTemplate = self.old_state.apps.get_model('part', 'partparametertemplate') + PartParameterTemplate = self.old_state.apps.get_model( + 'part', 'partparametertemplate' + ) # Create a test template - template = PartParameterTemplate.objects.create(name='Template 1', description='a part parameter template') + template = PartParameterTemplate.objects.create( + name='Template 1', description='a part parameter template' + ) # Ensure that the 'choices' and 'checkbox' fields do not exist with self.assertRaises(AttributeError): @@ -210,7 +221,9 @@ class TestPartParameterTemplateMigration(MigratorTestCase): def test_units_migration(self): """Test that the new fields have been added correctly""" - PartParameterTemplate = self.new_state.apps.get_model('part', 'partparametertemplate') + PartParameterTemplate = self.new_state.apps.get_model( + 'part', 'partparametertemplate' + ) template = PartParameterTemplate.objects.get(name='Template 1') diff --git a/InvenTree/part/test_param.py b/InvenTree/part/test_param.py index 827b8a369b..528dfda299 100644 --- a/InvenTree/part/test_param.py +++ b/InvenTree/part/test_param.py @@ -7,19 +7,19 @@ from django.urls import reverse from common.models import InvenTreeSetting from InvenTree.unit_test import InvenTreeAPITestCase -from .models import (Part, PartCategory, PartCategoryParameterTemplate, - PartParameter, PartParameterTemplate) +from .models import ( + Part, + PartCategory, + PartCategoryParameterTemplate, + PartParameter, + PartParameterTemplate, +) class TestParams(TestCase): """Unit test class for testing the PartParameter model""" - fixtures = [ - 'location', - 'category', - 'part', - 'params' - ] + fixtures = ['location', 'category', 'part', 'params'] def test_str(self): """Test the str representation of the PartParameterTemplate model""" @@ -81,12 +81,7 @@ class TestParams(TestCase): class TestCategoryTemplates(TransactionTestCase): """Test class for PartCategoryParameterTemplate model""" - fixtures = [ - 'location', - 'category', - 'part', - 'params' - ] + fixtures = ['location', 'category', 'part', 'params'] def test_validate(self): """Test that category templates are correctly applied to Part instances""" @@ -97,9 +92,9 @@ class TestCategoryTemplates(TransactionTestCase): category = PartCategory.objects.get(pk=8) t1 = PartParameterTemplate.objects.get(pk=2) - c1 = PartCategoryParameterTemplate(category=category, - parameter_template=t1, - default_value='xyz') + c1 = PartCategoryParameterTemplate( + category=category, parameter_template=t1, default_value='xyz' + ) c1.save() n = PartCategoryParameterTemplate.objects.all().count() @@ -109,19 +104,14 @@ class TestCategoryTemplates(TransactionTestCase): class ParameterTests(TestCase): """Unit tests for parameter validation""" - fixtures = [ - 'location', - 'category', - 'part', - 'params' - ] + fixtures = ['location', 'category', 'part', 'params'] def test_choice_validation(self): """Test that parameter choices are correctly validated""" template = PartParameterTemplate.objects.create( name='My Template', description='A template with choices', - choices='red, blue, green' + choices='red, blue, green', ) pass_values = ['red', 'blue', 'green'] @@ -141,7 +131,21 @@ class ParameterTests(TestCase): def test_unit_validation(self): """Test validation of 'units' field for PartParameterTemplate""" # Test that valid units pass - for unit in [None, '', '%', 'mm', 'A', 'm^2', 'Pa', 'V', 'C', 'F', 'uF', 'mF', 'millifarad']: + for unit in [ + None, + '', + '%', + 'mm', + 'A', + 'm^2', + 'Pa', + 'V', + 'C', + 'F', + 'uF', + 'mF', + 'millifarad', + ]: tmp = PartParameterTemplate(name='test', units=unit) tmp.full_clean() @@ -153,22 +157,29 @@ class ParameterTests(TestCase): def test_param_unit_validation(self): """Test that parameters are correctly validated against template units""" - template = PartParameterTemplate.objects.create( - name='My Template', - units='m', - ) + template = PartParameterTemplate.objects.create(name='My Template', units='m') prt = Part.objects.get(pk=1) # Test that valid parameters pass - for value in ['1', '1m', 'm', '-4m', -2, '2.032mm', '99km', '-12 mile', 'foot', '3 yards']: + for value in [ + '1', + '1m', + 'm', + '-4m', + -2, + '2.032mm', + '99km', + '-12 mile', + 'foot', + '3 yards', + ]: param = PartParameter(part=prt, template=template, data=value) param.full_clean() # Test that percent unit is working template2 = PartParameterTemplate.objects.create( - name='My Template 2', - units='%', + name='My Template 2', units='%' ) for value in ["1", "1%", "1 percent"]: param = PartParameter(part=prt, template=template2, data=value) @@ -177,7 +188,9 @@ class ParameterTests(TestCase): bad_values = ['3 Amps', '-3 zogs', '3.14F'] # Disable enforcing of part parameter units - InvenTreeSetting.set_setting('PART_PARAMETER_ENFORCE_UNITS', False, change_user=None) + InvenTreeSetting.set_setting( + 'PART_PARAMETER_ENFORCE_UNITS', False, change_user=None + ) # Invalid units also pass, but will be converted to the template units for value in bad_values: @@ -185,7 +198,9 @@ class ParameterTests(TestCase): param.full_clean() # Enable enforcing of part parameter units - InvenTreeSetting.set_setting('PART_PARAMETER_ENFORCE_UNITS', True, change_user=None) + InvenTreeSetting.set_setting( + 'PART_PARAMETER_ENFORCE_UNITS', True, change_user=None + ) for value in bad_values: param = PartParameter(part=prt, template=template, data=value) @@ -194,10 +209,7 @@ class ParameterTests(TestCase): def test_param_unit_conversion(self): """Test that parameters are correctly converted to template units""" - template = PartParameterTemplate.objects.create( - name='My Template', - units='m', - ) + template = PartParameterTemplate.objects.create(name='My Template', units='m') tests = { '1': 1.0, @@ -219,14 +231,10 @@ class ParameterTests(TestCase): class PartParameterTest(InvenTreeAPITestCase): """Tests for the ParParameter API.""" + superuser = True - fixtures = [ - 'category', - 'part', - 'location', - 'params', - ] + fixtures = ['category', 'part', 'location', 'params'] def test_list_params(self): """Test for listing part parameters.""" @@ -237,22 +245,12 @@ class PartParameterTest(InvenTreeAPITestCase): self.assertEqual(len(response.data), 7) # Filter by part - response = self.get( - url, - { - 'part': 3, - } - ) + response = self.get(url, {'part': 3}) self.assertEqual(len(response.data), 3) # Filter by template - response = self.get( - url, - { - 'template': 1, - } - ) + response = self.get(url, {'template': 1}) self.assertEqual(len(response.data), 4) @@ -261,10 +259,7 @@ class PartParameterTest(InvenTreeAPITestCase): # Checkbox parameter cannot have "units" specified with self.assertRaises(django_exceptions.ValidationError): template = PartParameterTemplate( - name='test', - description='My description', - units='mm', - checkbox=True + name='test', description='My description', units='mm', checkbox=True ) template.clean() @@ -275,7 +270,7 @@ class PartParameterTest(InvenTreeAPITestCase): name='test', description='My description', choices='a,b,c', - checkbox=True + checkbox=True, ) template.clean() @@ -283,9 +278,7 @@ class PartParameterTest(InvenTreeAPITestCase): # Choices must be 'unique' with self.assertRaises(django_exceptions.ValidationError): template = PartParameterTemplate( - name='test', - description='My description', - choices='a,a,b', + name='test', description='My description', choices='a,a,b' ) template.clean() @@ -294,14 +287,7 @@ class PartParameterTest(InvenTreeAPITestCase): """Test that we can create a param via the API.""" url = reverse('api-part-parameter-list') - response = self.post( - url, - { - 'part': '2', - 'template': '3', - 'data': 70 - } - ) + response = self.post(url, {'part': '2', 'template': '3', 'data': 70}) self.assertEqual(response.status_code, 201) @@ -351,9 +337,7 @@ class PartParameterTest(InvenTreeAPITestCase): # Create a new parameter template template = PartParameterTemplate.objects.create( - name='Test Template', - description='My test template', - units='m' + name='Test Template', description='My test template', units='m' ) # Create parameters for each existing part @@ -362,7 +346,6 @@ class PartParameterTest(InvenTreeAPITestCase): parts = Part.objects.all().order_by('pk') for idx, part in enumerate(parts): - # Skip parts every now and then if idx % 10 == 7: continue @@ -371,9 +354,7 @@ class PartParameterTest(InvenTreeAPITestCase): params.append( PartParameter.objects.create( - part=part, - template=template, - data=f'{idx}{suffix}' + part=part, template=template, data=f'{idx}{suffix}' ) ) @@ -382,25 +363,15 @@ class PartParameterTest(InvenTreeAPITestCase): response = self.get( url, - { - 'ordering': 'parameter_{pk}'.format(pk=template.pk), - 'parameters': 'true', - }, - expected_code=200 + {'ordering': 'parameter_{pk}'.format(pk=template.pk), 'parameters': 'true'}, + expected_code=200, ) # All parts should be returned self.assertEqual(len(response.data), len(parts)) # Check that the parts are ordered correctly (in increasing order) - expectation = { - 0: '0mm', - 1: '3mm', - 7: '4m', - 9: '8m', - -2: '13m', - -1: None, - } + expectation = {0: '0mm', 1: '3mm', 7: '4m', 9: '8m', -2: '13m', -1: None} for idx, expected in expectation.items(): actual = get_param_value(response, template.pk, idx) @@ -413,16 +384,10 @@ class PartParameterTest(InvenTreeAPITestCase): 'ordering': '-parameter_{pk}'.format(pk=template.pk), 'parameters': 'true', }, - expected_code=200 + expected_code=200, ) - expectation = { - 0: '13m', - 1: '11m', - -3: '3mm', - -2: '0mm', - -1: None, - } + expectation = {0: '13m', 1: '11m', -3: '3mm', -2: '0mm', -1: None} for idx, expected in expectation.items(): actual = get_param_value(response, template.pk, idx) diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index bc9857c614..e74d2d08f5 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -10,15 +10,26 @@ from django.test import TestCase from allauth.account.models import EmailAddress import part.settings -from common.models import (InvenTreeSetting, InvenTreeUserSetting, - NotificationEntry, NotificationMessage) +from common.models import ( + InvenTreeSetting, + InvenTreeUserSetting, + NotificationEntry, + NotificationMessage, +) from common.notifications import UIMessageNotification, storage from InvenTree import version from InvenTree.unit_test import InvenTreeTestCase -from .models import (Part, PartCategory, PartCategoryStar, PartRelated, - PartStar, PartStocktake, PartTestTemplate, - rename_part_image) +from .models import ( + Part, + PartCategory, + PartCategoryStar, + PartRelated, + PartStar, + PartStocktake, + PartTestTemplate, + rename_part_image, +) from .templatetags import inventree_extras @@ -54,11 +65,16 @@ class TemplateTagTest(InvenTreeTestCase): def test_inventree_is_release(self): """Test that the release version check functions as expected""" - self.assertEqual(inventree_extras.inventree_is_release(), not version.isInvenTreeDevelopmentVersion()) + self.assertEqual( + inventree_extras.inventree_is_release(), + not version.isInvenTreeDevelopmentVersion(), + ) def test_inventree_docs_version(self): """Test that the documentation version template tag returns correctly""" - self.assertEqual(inventree_extras.inventree_docs_version(), version.inventreeDocsVersion()) + self.assertEqual( + inventree_extras.inventree_docs_version(), version.inventreeDocsVersion() + ) def test_hash(self): """Test that the commit hash template tag returns correctly""" @@ -127,12 +143,7 @@ class TemplateTagTest(InvenTreeTestCase): class PartTest(TestCase): """Tests for the Part model.""" - fixtures = [ - 'category', - 'part', - 'location', - 'part_pricebreaks' - ] + fixtures = ['category', 'part', 'location', 'part_pricebreaks'] @classmethod def setUpTestData(cls): @@ -352,12 +363,7 @@ class PartTest(TestCase): class TestTemplateTest(TestCase): """Unit test for the TestTemplate class""" - fixtures = [ - 'category', - 'part', - 'location', - 'test_templates', - ] + fixtures = ['category', 'part', 'location', 'test_templates'] def test_template_count(self): """Tests for the test template functions""" @@ -381,22 +387,17 @@ class TestTemplateTest(TestCase): variant = Part.objects.get(pk=10004) with self.assertRaises(ValidationError): - PartTestTemplate.objects.create( - part=variant, - test_name='Record weight' - ) + PartTestTemplate.objects.create(part=variant, test_name='Record weight') with self.assertRaises(ValidationError): PartTestTemplate.objects.create( - part=variant, - test_name='Check that chair is especially green' + part=variant, test_name='Check that chair is especially green' ) # Also should fail if we attempt to create a test that would generate the same key with self.assertRaises(ValidationError): PartTestTemplate.objects.create( - part=variant, - test_name='ReCoRD weiGHT ' + part=variant, test_name='ReCoRD weiGHT ' ) # But we should be able to create a new one! @@ -418,9 +419,7 @@ class PartSettingsTest(InvenTreeTestCase): cache.clear() part = Part.objects.create( - name='Test Part', - description='I am but a humble test part', - IPN='IPN-123', + name='Test Part', description='I am but a humble test part', IPN='IPN-123' ) return part @@ -474,7 +473,9 @@ class PartSettingsTest(InvenTreeTestCase): def test_duplicate_ipn(self): """Test the setting which controls duplicate IPN values.""" # Create a part - Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='A') + Part.objects.create( + name='Hello', description='A thing', IPN='IPN123', revision='A' + ) # Attempt to create a duplicate item (should fail) with self.assertRaises(ValidationError): @@ -482,7 +483,9 @@ class PartSettingsTest(InvenTreeTestCase): part.validate_unique() # Attempt to create item with duplicate IPN (should be allowed by default) - Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='B') + Part.objects.create( + name='Hello', description='A thing', IPN='IPN123', revision='B' + ) # And attempt again with the same values (should fail) with self.assertRaises(ValidationError): @@ -497,12 +500,16 @@ class PartSettingsTest(InvenTreeTestCase): part.full_clean() # Any duplicate IPN should raise an error - Part.objects.create(name='xyz', revision='1', description='A part', IPN='UNIQUE') + Part.objects.create( + name='xyz', revision='1', description='A part', IPN='UNIQUE' + ) # Case insensitive, so variations on spelling should throw an error for ipn in ['UNiquE', 'uniQuE', 'unique']: with self.assertRaises(ValidationError): - Part.objects.create(name='xyz', revision='2', description='A part', IPN=ipn) + Part.objects.create( + name='xyz', revision='2', description='A part', IPN=ipn + ) with self.assertRaises(ValidationError): Part.objects.create(name='zyx', description='A part', IPN='UNIQUE') @@ -520,11 +527,7 @@ class PartSettingsTest(InvenTreeTestCase): class PartSubscriptionTests(InvenTreeTestCase): """Unit tests for part 'subscription'""" - fixtures = [ - 'location', - 'category', - 'part', - ] + fixtures = ['location', 'category', 'part'] @classmethod def setUpTestData(cls): @@ -562,9 +565,7 @@ class PartSubscriptionTests(InvenTreeTestCase): """Test subscription against a parent part.""" # Construct a sub-part to star against sub_part = Part.objects.create( - name='sub_part', - description='a sub part', - variant_of=self.part, + name='sub_part', description='a sub part', variant_of=self.part ) self.assertFalse(sub_part.is_starred_by(self.user)) @@ -620,12 +621,7 @@ class PartSubscriptionTests(InvenTreeTestCase): class BaseNotificationIntegrationTest(InvenTreeTestCase): """Integration test for notifications.""" - fixtures = [ - 'location', - 'category', - 'part', - 'stock' - ] + fixtures = ['location', 'category', 'part', 'stock'] @classmethod def setUpTestData(cls): @@ -652,7 +648,9 @@ class BaseNotificationIntegrationTest(InvenTreeTestCase): self.assertEqual(NotificationEntry.objects.all().count(), 0) # Test that notifications run through without errors - self.part.minimum_stock = self.part.get_stock_count() + 1 # make sure minimum is one higher than current count + self.part.minimum_stock = ( + self.part.get_stock_count() + 1 + ) # make sure minimum is one higher than current count self.part.save() # There should be no notification as no-one is subscribed diff --git a/InvenTree/part/test_pricing.py b/InvenTree/part/test_pricing.py index e6a348f413..5d0aac2bf4 100644 --- a/InvenTree/part/test_pricing.py +++ b/InvenTree/part/test_pricing.py @@ -31,15 +31,14 @@ class PartPricingTests(InvenTreeTestCase): name='PP', description='A part with pricing, measured in metres', assembly=True, - units='m' + units='m', ) def create_price_breaks(self): """Create some price breaks for the part, in various currencies""" # First supplier part (CAD) self.supplier_1 = company.models.Company.objects.create( - name='Supplier 1', - is_supplier=True + name='Supplier 1', is_supplier=True ) self.sp_1 = company.models.SupplierPart.objects.create( @@ -53,23 +52,16 @@ class PartPricingTests(InvenTreeTestCase): self.assertEqual(self.sp_1.pack_quantity_native, 2) company.models.SupplierPriceBreak.objects.create( - part=self.sp_1, - quantity=1, - price=10.4, - price_currency='CAD', + part=self.sp_1, quantity=1, price=10.4, price_currency='CAD' ) # Second supplier part (AUD) self.supplier_2 = company.models.Company.objects.create( - name='Supplier 2', - is_supplier=True + name='Supplier 2', is_supplier=True ) self.sp_2 = company.models.SupplierPart.objects.create( - supplier=self.supplier_2, - part=self.part, - SKU='SUP_2', - pack_quantity='2.5', + supplier=self.supplier_2, part=self.part, SKU='SUP_2', pack_quantity='2.5' ) # Native pack quantity should be 2.5m @@ -86,18 +78,12 @@ class PartPricingTests(InvenTreeTestCase): self.assertEqual(self.sp_3.pack_quantity_native, 0.254) company.models.SupplierPriceBreak.objects.create( - part=self.sp_2, - quantity=5, - price=7.555, - price_currency='AUD', + part=self.sp_2, quantity=5, price=7.555, price_currency='AUD' ) # Third supplier part (GBP) company.models.SupplierPriceBreak.objects.create( - part=self.sp_2, - quantity=10, - price=4.55, - price_currency='GBP', + part=self.sp_2, quantity=10, price=4.55, price_currency='GBP' ) def test_pricing_data(self): @@ -198,10 +184,7 @@ class PartPricingTests(InvenTreeTestCase): for ii in range(5): # Let's add some internal price breaks part.models.PartInternalPriceBreak.objects.create( - part=self.part, - quantity=ii + 1, - price=10 - ii, - price_currency=currency + part=self.part, quantity=ii + 1, price=10 - ii, price_currency=currency ) pricing.update_internal_cost() @@ -226,23 +209,20 @@ class PartPricingTests(InvenTreeTestCase): ) # Create some stock items - prices = [ - (10, 'AUD'), - (5, 'USD'), - (2, 'CAD'), - ] + prices = [(10, 'AUD'), (5, 'USD'), (2, 'CAD')] for price, currency in prices: - stock.models.StockItem.objects.create( part=p, quantity=10, purchase_price=price, - purchase_price_currency=currency + purchase_price_currency=currency, ) # Ensure that initially, stock item pricing is disabled - common.models.InvenTreeSetting.set_setting('PRICING_USE_STOCK_PRICING', False, None) + common.models.InvenTreeSetting.set_setting( + 'PRICING_USE_STOCK_PRICING', False, None + ) pricing = p.pricing pricing.update_pricing() @@ -254,7 +234,9 @@ class PartPricingTests(InvenTreeTestCase): self.assertIsNone(pricing.overall_max) # Turn on stock pricing - common.models.InvenTreeSetting.set_setting('PRICING_USE_STOCK_PRICING', True, None) + common.models.InvenTreeSetting.set_setting( + 'PRICING_USE_STOCK_PRICING', True, None + ) pricing.update_pricing() @@ -291,9 +273,7 @@ class PartPricingTests(InvenTreeTestCase): sub_part_pricing.save() part.models.BomItem.objects.create( - part=self.part, - sub_part=sub_part, - quantity=5, + part=self.part, sub_part=sub_part, quantity=5 ) pricing.update_bom_cost() @@ -318,14 +298,15 @@ class PartPricingTests(InvenTreeTestCase): # Generate some purchase orders po = order.models.PurchaseOrder.objects.create( - supplier=self.supplier_2, - reference='PO-009', + supplier=self.supplier_2, reference='PO-009' ) # Add some line items to the order # $5 AUD each @ 2.5m per unit = $2 AUD per metre - line_1 = po.add_line_item(self.sp_2, quantity=10, purchase_price=Money(5, 'AUD')) + line_1 = po.add_line_item( + self.sp_2, quantity=10, purchase_price=Money(5, 'AUD') + ) # $3 CAD each @ 10 inches per unit = $0.3 CAD per inch = $11.81 CAD per metre line_2 = po.add_line_item(self.sp_3, quantity=5, purchase_price=Money(3, 'CAD')) @@ -361,7 +342,9 @@ class PartPricingTests(InvenTreeTestCase): self.assertAlmostEqual(float(min_cost_aud.amount), 2, places=2) # Min cost in USD - self.assertAlmostEqual(float(pricing.purchase_cost_min.amount), 1.3333, places=2) + self.assertAlmostEqual( + float(pricing.purchase_cost_min.amount), 1.3333, places=2 + ) # Max cost in CAD = $11.81 CAD per metre self.assertAlmostEqual(float(max_cost_cad.amount), 11.81, places=2) @@ -420,8 +403,7 @@ class PartPricingTests(InvenTreeTestCase): # Create some parts for ii in range(100): part.models.Part.objects.create( - name=f"Part_{ii}", - description="A test part", + name=f"Part_{ii}", description="A test part" ) # Ensure there is no pricing data @@ -442,17 +424,13 @@ class PartPricingTests(InvenTreeTestCase): but it pointed to a Part instance which was slated to be deleted inside an atomic transaction. """ p = part.models.Part.objects.create( - name="my part", - description="my part description", - active=False, + name="my part", description="my part description", active=False ) # Create some stock items for _idx in range(3): stock.models.StockItem.objects.create( - part=p, - quantity=10, - purchase_price=Money(10, 'USD') + part=p, quantity=10, purchase_price=Money(10, 'USD') ) # Manually schedule a pricing update (does not happen automatically in testing) diff --git a/InvenTree/part/test_views.py b/InvenTree/part/test_views.py index a43bb91134..6674f6d0f5 100644 --- a/InvenTree/part/test_views.py +++ b/InvenTree/part/test_views.py @@ -10,14 +10,7 @@ from .models import Part class PartViewTestCase(InvenTreeTestCase): """Base class for unit testing the various Part views""" - fixtures = [ - 'category', - 'part', - 'bom', - 'location', - 'company', - 'supplier_part', - ] + fixtures = ['category', 'part', 'bom', 'location', 'company', 'supplier_part'] roles = 'all' superuser = True @@ -72,7 +65,9 @@ class PartDetailTest(PartViewTestCase): index_redirect = False detail_redirect = False - response = self.client.get(reverse('part-detail-from-ipn', args=(ipn_test,))) + response = self.client.get( + reverse('part-detail-from-ipn', args=(ipn_test,)) + ) # Check for PartIndex redirect try: @@ -110,6 +105,9 @@ class PartDetailTest(PartViewTestCase): def test_bom_download(self): """Test downloading a BOM for a valid part.""" - response = self.client.get(reverse('api-bom-download', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') + response = self.client.get( + reverse('api-bom-download', args=(1,)), + HTTP_X_REQUESTED_WITH='XMLHttpRequest', + ) self.assertEqual(response.status_code, 200) self.assertIn('streaming_content', dir(response)) diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 6654c3a9ea..7e6a4e1f8f 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -12,37 +12,37 @@ from . import views part_detail_urls = [ re_path(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'), - # Normal thumbnail with form - re_path(r'^thumb-select/?', views.PartImageSelect.as_view(), name='part-image-select'), - + re_path( + r'^thumb-select/?', views.PartImageSelect.as_view(), name='part-image-select' + ), # Any other URLs go to the part detail page re_path(r'^.*$', views.PartDetail.as_view(), name='part-detail'), ] category_urls = [ - # Category detail views - re_path(r'(?P\d+)/', views.CategoryDetail.as_view(), name='category-detail'), + re_path(r'(?P\d+)/', views.CategoryDetail.as_view(), name='category-detail') ] # URL list for part web interface part_urls = [ - # Upload a part re_path(r'^import/$', views.PartImport.as_view(), name='part-import'), - re_path(r'^import/?', views.PartImportTemplate.as_view(), name='part-template-download'), + re_path( + r'^import/?', views.PartImportTemplate.as_view(), name='part-template-download' + ), re_path(r'^import-api/', views.PartImportAjax.as_view(), name='api-part-import'), - # Individual part using pk path(r'/', include(part_detail_urls)), - # Part category re_path(r'^category/', include(category_urls)), - # Individual part using IPN as slug - re_path(r'^(?P[-\w]+)/', views.PartDetailFromIPN.as_view(), name='part-detail-from-ipn'), - + re_path( + r'^(?P[-\w]+)/', + views.PartDetailFromIPN.as_view(), + name='part-detail-from-ipn', + ), # Top level part list (display top level parts and categories) re_path(r'^.*$', views.PartIndex.as_view(), name='part-index'), ] diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index b8518ea64f..fb116eb6d5 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -60,14 +60,13 @@ class PartIndex(InvenTreeRoleMixin, InvenTreePluginViewMixin, ListView): class PartImport(FileManagementFormView): """Part: Upload file, match to fields and import parts(using multi-Step form)""" + permission_required = 'part.add' class PartFileManager(FileManager): """Import field definitions""" - REQUIRED_HEADERS = [ - 'Name', - 'Description', - ] + + REQUIRED_HEADERS = ['Name', 'Description'] OPTIONAL_MATCH_HEADERS = [ 'Category', @@ -147,9 +146,13 @@ class PartImport(FileManagementFormView): self.allowed_items = {} self.matches = {} - self.allowed_items['Category'] = PartCategory.objects.all().exclude(structural=True) + self.allowed_items['Category'] = PartCategory.objects.all().exclude( + structural=True + ) self.matches['Category'] = ['name__icontains'] - self.allowed_items['default_location'] = StockLocation.objects.all().exclude(structural=True) + self.allowed_items['default_location'] = StockLocation.objects.all().exclude( + structural=True + ) self.matches['default_location'] = ['name__icontains'] self.allowed_items['default_supplier'] = SupplierPart.objects.all() self.matches['default_supplier'] = ['SKU__icontains'] @@ -169,7 +172,6 @@ class PartImport(FileManagementFormView): for row in self.rows: # check each submitted column for idx in col_ids: - try: data = row['data'][col_ids[idx]]['cell'] except (IndexError, TypeError): @@ -177,8 +179,14 @@ class PartImport(FileManagementFormView): if idx in self.file_manager.OPTIONAL_MATCH_HEADERS: try: - exact_match = self.allowed_items[idx].get(**{a: data for a in self.matches[idx]}) - except (ValueError, self.allowed_items[idx].model.DoesNotExist, self.allowed_items[idx].model.MultipleObjectsReturned): + exact_match = self.allowed_items[idx].get(**{ + a: data for a in self.matches[idx] + }) + except ( + ValueError, + self.allowed_items[idx].model.DoesNotExist, + self.allowed_items[idx].model.MultipleObjectsReturned, + ): exact_match = None row['match_options_' + idx] = self.allowed_items[idx] @@ -197,14 +205,19 @@ class PartImport(FileManagementFormView): # Create Part instances for part_data in items.values(): - # set related parts optional_matches = {} for idx in self.file_manager.OPTIONAL_MATCH_HEADERS: if idx.lower() in part_data: try: - optional_matches[idx] = self.allowed_items[idx].get(pk=int(part_data[idx.lower()])) - except (ValueError, self.allowed_items[idx].model.DoesNotExist, self.allowed_items[idx].model.MultipleObjectsReturned): + optional_matches[idx] = self.allowed_items[idx].get( + pk=int(part_data[idx.lower()]) + ) + except ( + ValueError, + self.allowed_items[idx].model.DoesNotExist, + self.allowed_items[idx].model.MultipleObjectsReturned, + ): optional_matches[idx] = None else: optional_matches[idx] = None @@ -228,19 +241,39 @@ class PartImport(FileManagementFormView): active=str2bool(part_data.get('active', True)), base_cost=str2int(part_data.get('base_cost'), 0), multiple=str2int(part_data.get('multiple'), 1), - assembly=str2bool(part_data.get('assembly', part_settings.part_assembly_default())), - component=str2bool(part_data.get('component', part_settings.part_component_default())), - is_template=str2bool(part_data.get('is_template', part_settings.part_template_default())), - purchaseable=str2bool(part_data.get('purchaseable', part_settings.part_purchaseable_default())), - salable=str2bool(part_data.get('salable', part_settings.part_salable_default())), - trackable=str2bool(part_data.get('trackable', part_settings.part_trackable_default())), - virtual=str2bool(part_data.get('virtual', part_settings.part_virtual_default())), + assembly=str2bool( + part_data.get('assembly', part_settings.part_assembly_default()) + ), + component=str2bool( + part_data.get('component', part_settings.part_component_default()) + ), + is_template=str2bool( + part_data.get('is_template', part_settings.part_template_default()) + ), + purchaseable=str2bool( + part_data.get( + 'purchaseable', part_settings.part_purchaseable_default() + ) + ), + salable=str2bool( + part_data.get('salable', part_settings.part_salable_default()) + ), + trackable=str2bool( + part_data.get('trackable', part_settings.part_trackable_default()) + ), + virtual=str2bool( + part_data.get('virtual', part_settings.part_virtual_default()) + ), image=part_data.get('image', None), ) # check if there's a category assigned, if not skip this part or else bad things happen if not optional_matches['Category']: - import_error.append(_(f"Can't import part {new_part.name} because there is no category assigned")) + import_error.append( + _( + f"Can't import part {new_part.name} because there is no category assigned" + ) + ) continue try: @@ -264,8 +297,14 @@ class PartImport(FileManagementFormView): alert = f"{_('Part-Import')}
{_(f'Imported {import_done} parts')}" messages.success(self.request, alert) if import_error: - error_text = '\n'.join([f'
  • {import_error.count(a)}: {a}
  • ' for a in set(import_error)]) - messages.error(self.request, f"{_('Some errors occurred:')}
      {error_text}
    ") + error_text = '\n'.join([ + f'
  • {import_error.count(a)}: {a}
  • ' + for a in set(import_error) + ]) + messages.error( + self.request, + f"{_('Some errors occurred:')}
      {error_text}
    ", + ) return HttpResponseRedirect(reverse('part-index')) @@ -285,6 +324,7 @@ class PartImportTemplate(AjaxView): class PartImportAjax(FileManagementAjaxView, PartImport): """Multi-step form wizard for importing Part data""" + ajax_form_steps_template = [ 'part/import_wizard/ajax_part_upload.html', 'part/import_wizard/ajax_match_fields.html', @@ -383,9 +423,7 @@ class PartImageSelect(AjaxUpdateView): ajax_template_name = 'part/select_image.html' ajax_form_title = _('Select Part Image') - fields = [ - 'image', - ] + fields = ['image'] def post(self, request, *args, **kwargs): """Perform POST action to assign selected image to the Part instance""" @@ -403,7 +441,6 @@ class PartImageSelect(AjaxUpdateView): # Ensure that the image already exists if os.path.exists(img_path): - part.image = os.path.join(PART_IMAGE_DIR, img) part.save() @@ -481,23 +518,22 @@ class BomDownload(AjaxView): if not IsValidBOMFormat(export_format): export_format = 'csv' - return ExportBom(part, - fmt=export_format, - cascade=cascade, - max_levels=levels, - parameter_data=parameter_data, - stock_data=stock_data, - supplier_data=supplier_data, - manufacturer_data=manufacturer_data, - pricing_data=pricing_data, - substitute_part_data=substitute_part_data, - ) + return ExportBom( + part, + fmt=export_format, + cascade=cascade, + max_levels=levels, + parameter_data=parameter_data, + stock_data=stock_data, + supplier_data=supplier_data, + manufacturer_data=manufacturer_data, + pricing_data=pricing_data, + substitute_part_data=substitute_part_data, + ) def get_data(self): """Return a custom message""" - return { - 'info': 'Exported BOM' - } + return {'info': 'Exported BOM'} class PartPricing(AjaxView): @@ -534,11 +570,7 @@ class PartPricing(AjaxView): part = self.get_part() - ctx = { - 'part': part, - 'quantity': quantity, - 'currency': currency, - } + ctx = {'part': part, 'quantity': quantity, 'currency': currency} if part is None: return ctx @@ -569,8 +601,9 @@ class PartPricing(AjaxView): # BOM pricing information if part.bom_count > 0: - - use_internal = InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False) + use_internal = InvenTreeSetting.get_setting( + 'PART_BOM_USE_INTERNAL_PRICE', False + ) bom_price = part.get_bom_price_range(quantity, internal=use_internal) purchase_price = part.get_bom_price_range(quantity, purchase=True) @@ -594,12 +627,20 @@ class PartPricing(AjaxView): min_bom_purchase_price /= scaler max_bom_purchase_price /= scaler if min_bom_purchase_price: - ctx['min_total_bom_purchase_price'] = round(min_bom_purchase_price, 3) - ctx['min_unit_bom_purchase_price'] = round(min_bom_purchase_price / quantity, 3) + ctx['min_total_bom_purchase_price'] = round( + min_bom_purchase_price, 3 + ) + ctx['min_unit_bom_purchase_price'] = round( + min_bom_purchase_price / quantity, 3 + ) if max_bom_purchase_price: - ctx['max_total_bom_purchase_price'] = round(max_bom_purchase_price, 3) - ctx['max_unit_bom_purchase_price'] = round(max_bom_purchase_price / quantity, 3) + ctx['max_total_bom_purchase_price'] = round( + max_bom_purchase_price, 3 + ) + ctx['max_unit_bom_purchase_price'] = round( + max_bom_purchase_price / quantity, 3 + ) # internal part pricing information internal_part_price = part.get_internal_price(quantity) @@ -624,7 +665,9 @@ class PartPricing(AjaxView): init = self.get_initials() qty = self.get_quantity() - return self.renderJsonResponse(request, self.form_class(initial=init), context=self.get_pricing(qty)) + return self.renderJsonResponse( + request, self.form_class(initial=init), context=self.get_pricing(qty) + ) def post(self, request, *args, **kwargs): """Perform custom POST action for this view""" @@ -647,7 +690,9 @@ class PartPricing(AjaxView): # Always mark the form as 'invalid' (the user may wish to keep getting pricing data) data['form_valid'] = False - return self.renderJsonResponse(request, form, data=data, context=self.get_pricing(quantity, currency)) + return self.renderJsonResponse( + request, form, data=data, context=self.get_pricing(quantity, currency) + ) class CategoryDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView): @@ -676,11 +721,9 @@ class CategoryDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView): category = kwargs.get('object', None) if category: - # Insert "starred" information context['starred_directly'] = category.is_starred_by( - self.request.user, - include_parents=False, + self.request.user, include_parents=False ) if context['starred_directly']: diff --git a/InvenTree/plugin/__init__.py b/InvenTree/plugin/__init__.py index db8a8ea10a..63a634fe20 100644 --- a/InvenTree/plugin/__init__.py +++ b/InvenTree/plugin/__init__.py @@ -6,7 +6,6 @@ from .registry import registry __all__ = [ 'registry', - 'InvenTreePlugin', 'MixinNotImplementedError', 'MixinImplementationError', diff --git a/InvenTree/plugin/admin.py b/InvenTree/plugin/admin.py index 42f02c0968..23bee2fcc5 100644 --- a/InvenTree/plugin/admin.py +++ b/InvenTree/plugin/admin.py @@ -39,9 +39,7 @@ class PluginSettingInline(admin.TabularInline): model = models.PluginSetting - read_only_fields = [ - 'key', - ] + read_only_fields = ['key'] def has_add_permission(self, request, obj): """The plugin settings should not be meddled with manually.""" @@ -51,12 +49,20 @@ class PluginSettingInline(admin.TabularInline): class PluginConfigAdmin(admin.ModelAdmin): """Custom admin with restricted id fields.""" - readonly_fields = ["key", "name", ] - list_display = ['name', 'key', '__str__', 'active', 'is_builtin', 'is_sample', 'is_installed'] + readonly_fields = ["key", "name"] + list_display = [ + 'name', + 'key', + '__str__', + 'active', + 'is_builtin', + 'is_sample', + 'is_installed', + ] list_filter = ['active'] - actions = [plugin_activate, plugin_deactivate, ] - inlines = [PluginSettingInline, ] - exclude = ['metadata', ] + actions = [plugin_activate, plugin_deactivate] + inlines = [PluginSettingInline] + exclude = ['metadata'] class NotificationUserSettingAdmin(admin.ModelAdmin): @@ -64,9 +70,7 @@ class NotificationUserSettingAdmin(admin.ModelAdmin): model = models.NotificationUserSetting - read_only_fields = [ - 'key', - ] + read_only_fields = ['key'] def has_add_permission(self, request): """Notifications should not be changed.""" diff --git a/InvenTree/plugin/api.py b/InvenTree/plugin/api.py index 4e04b0b5b3..e2fbf99a45 100644 --- a/InvenTree/plugin/api.py +++ b/InvenTree/plugin/api.py @@ -14,8 +14,13 @@ from common.api import GlobalSettingsPermissions from InvenTree.api import MetadataView from InvenTree.filters import SEARCH_ORDER_FILTER from InvenTree.helpers import str2bool -from InvenTree.mixins import (CreateAPI, ListAPI, RetrieveUpdateAPI, - RetrieveUpdateDestroyAPI, UpdateAPI) +from InvenTree.mixins import ( + CreateAPI, + ListAPI, + RetrieveUpdateAPI, + RetrieveUpdateDestroyAPI, + UpdateAPI, +) from InvenTree.permissions import IsSuperuser from plugin import registry from plugin.base.action.api import ActionPluginView @@ -102,26 +107,13 @@ class PluginList(ListAPI): filter_backends = SEARCH_ORDER_FILTER - filterset_fields = [ - 'active', - ] + filterset_fields = ['active'] - ordering_fields = [ - 'key', - 'name', - 'active', - ] + ordering_fields = ['key', 'name', 'active'] - ordering = [ - '-active', - 'name', - 'key', - ] + ordering = ['-active', 'name', 'key'] - search_fields = [ - 'key', - 'name', - ] + search_fields = ['key', 'name'] class PluginDetail(RetrieveUpdateDestroyAPI): @@ -176,7 +168,7 @@ class PluginActivate(UpdateAPI): queryset = PluginConfig.objects.all() serializer_class = PluginSerializers.PluginActivateSerializer - permission_classes = [IsSuperuser, ] + permission_classes = [IsSuperuser] def get_object(self): """Returns the object for the view.""" @@ -194,7 +186,7 @@ class PluginReload(CreateAPI): queryset = PluginConfig.objects.none() serializer_class = PluginSerializers.PluginReloadSerializer - permission_classes = [IsSuperuser,] + permission_classes = [IsSuperuser] def perform_create(self, serializer): """Saving the serializer instance performs plugin installation""" @@ -211,18 +203,11 @@ class PluginSettingList(ListAPI): queryset = PluginSetting.objects.all() serializer_class = PluginSerializers.PluginSettingSerializer - permission_classes = [ - GlobalSettingsPermissions, - ] + permission_classes = [GlobalSettingsPermissions] - filter_backends = [ - DjangoFilterBackend, - ] + filter_backends = [DjangoFilterBackend] - filterset_fields = [ - 'plugin__active', - 'plugin__key', - ] + filterset_fields = ['plugin__active', 'plugin__key'] def check_plugin(plugin_slug: str, plugin_pk: int) -> InvenTreePlugin: @@ -282,7 +267,9 @@ class PluginAllSettingList(APIView): permission_classes = [GlobalSettingsPermissions] - @extend_schema(responses={200: PluginSerializers.PluginSettingSerializer(many=True)}) + @extend_schema( + responses={200: PluginSerializers.PluginSettingSerializer(many=True)} + ) def get(self, request, pk): """Get all settings for a plugin config.""" @@ -291,9 +278,13 @@ class PluginAllSettingList(APIView): settings = getattr(plugin, 'settings', {}) - settings_dict = PluginSetting.all_settings(settings_definition=settings, plugin=plugin.plugin_config()) + settings_dict = PluginSetting.all_settings( + settings_definition=settings, plugin=plugin.plugin_config() + ) - results = PluginSerializers.PluginSettingSerializer(list(settings_dict.values()), many=True).data + results = PluginSerializers.PluginSettingSerializer( + list(settings_dict.values()), many=True + ).data return Response(results) @@ -315,19 +306,21 @@ class PluginSettingDetail(RetrieveUpdateAPI): key = self.kwargs['key'] # Look up plugin - plugin = check_plugin(plugin_slug=self.kwargs.get('plugin'), plugin_pk=self.kwargs.get('pk')) + plugin = check_plugin( + plugin_slug=self.kwargs.get('plugin'), plugin_pk=self.kwargs.get('pk') + ) settings = getattr(plugin, 'settings', {}) if key not in settings: - raise NotFound(detail=f"Plugin '{plugin.slug}' has no setting matching '{key}'") + raise NotFound( + detail=f"Plugin '{plugin.slug}' has no setting matching '{key}'" + ) return PluginSetting.get_setting_object(key, plugin=plugin.plugin_config()) # Staff permission required - permission_classes = [ - GlobalSettingsPermissions, - ] + permission_classes = [GlobalSettingsPermissions] class RegistryStatusView(APIView): @@ -336,7 +329,7 @@ class RegistryStatusView(APIView): - GET: Provide status data for the plugin registry """ - permission_classes = [IsSuperuser, ] + permission_classes = [IsSuperuser] serializer_class = PluginSerializers.PluginRegistryStatusSerializer @@ -355,7 +348,7 @@ class RegistryStatusView(APIView): }) result = PluginSerializers.PluginRegistryStatusSerializer({ - "registry_errors": error_list, + "registry_errors": error_list }).data return Response(result) @@ -365,35 +358,73 @@ plugin_api_urls = [ re_path(r'^action/', ActionPluginView.as_view(), name='api-action-plugin'), re_path(r'^barcode/', include(barcode_api_urls)), re_path(r'^locate/', LocatePluginView.as_view(), name='api-locate-plugin'), - re_path(r'^plugins/', include([ - # Plugin settings URLs - re_path(r'^settings/', include([ - re_path(r'^(?P[-\w]+)/(?P\w+)/', PluginSettingDetail.as_view(), name='api-plugin-setting-detail'), # Used for admin interface - re_path(r'^.*$', PluginSettingList.as_view(), name='api-plugin-setting-list'), - ])), - - # Detail views for a single PluginConfig item - path(r'/', include([ - re_path(r"^settings/", include([ - re_path(r'^(?P\w+)/', PluginSettingDetail.as_view(), name='api-plugin-setting-detail-pk'), - re_path(r"^.*$", PluginAllSettingList.as_view(), name="api-plugin-settings"), - ])), - re_path(r'^activate/', PluginActivate.as_view(), name='api-plugin-detail-activate'), - re_path(r'^.*$', PluginDetail.as_view(), name='api-plugin-detail'), - ])), - - # Metadata - re_path('^metadata/', MetadataView.as_view(), {'model': PluginConfig}, name='api-plugin-metadata'), - - # Plugin management - re_path(r'^reload/', PluginReload.as_view(), name='api-plugin-reload'), - re_path(r'^install/', PluginInstall.as_view(), name='api-plugin-install'), - re_path(r'^activate/', PluginActivate.as_view(), name='api-plugin-activate'), - - # Registry status - re_path(r"^status/", RegistryStatusView.as_view(), name="api-plugin-registry-status"), - - # Anything else - re_path(r'^.*$', PluginList.as_view(), name='api-plugin-list'), - ])) + re_path( + r'^plugins/', + include([ + # Plugin settings URLs + re_path( + r'^settings/', + include([ + re_path( + r'^(?P[-\w]+)/(?P\w+)/', + PluginSettingDetail.as_view(), + name='api-plugin-setting-detail', + ), # Used for admin interface + re_path( + r'^.*$', + PluginSettingList.as_view(), + name='api-plugin-setting-list', + ), + ]), + ), + # Detail views for a single PluginConfig item + path( + r'/', + include([ + re_path( + r"^settings/", + include([ + re_path( + r'^(?P\w+)/', + PluginSettingDetail.as_view(), + name='api-plugin-setting-detail-pk', + ), + re_path( + r"^.*$", + PluginAllSettingList.as_view(), + name="api-plugin-settings", + ), + ]), + ), + re_path( + r'^activate/', + PluginActivate.as_view(), + name='api-plugin-detail-activate', + ), + re_path(r'^.*$', PluginDetail.as_view(), name='api-plugin-detail'), + ]), + ), + # Metadata + re_path( + '^metadata/', + MetadataView.as_view(), + {'model': PluginConfig}, + name='api-plugin-metadata', + ), + # Plugin management + re_path(r'^reload/', PluginReload.as_view(), name='api-plugin-reload'), + re_path(r'^install/', PluginInstall.as_view(), name='api-plugin-install'), + re_path( + r'^activate/', PluginActivate.as_view(), name='api-plugin-activate' + ), + # Registry status + re_path( + r"^status/", + RegistryStatusView.as_view(), + name="api-plugin-registry-status", + ), + # Anything else + re_path(r'^.*$', PluginList.as_view(), name='api-plugin-list'), + ]), + ), ] diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py index 74000de2a1..24bc34ab1f 100644 --- a/InvenTree/plugin/apps.py +++ b/InvenTree/plugin/apps.py @@ -36,14 +36,19 @@ class PluginAppConfig(AppConfig): # this is the first startup try: from common.models import InvenTreeSetting - if InvenTreeSetting.get_setting('PLUGIN_ON_STARTUP', create=False, cache=False): + + if InvenTreeSetting.get_setting( + 'PLUGIN_ON_STARTUP', create=False, cache=False + ): # make sure all plugins are installed registry.install_plugin_file() except Exception: # pragma: no cover pass # Perform a full reload of the plugin registry - registry.reload_plugins(full_reload=True, force_reload=True, collect=True) + registry.reload_plugins( + full_reload=True, force_reload=True, collect=True + ) # drop out of maintenance # makes sure we did not have an error in reloading and maintenance is still active diff --git a/InvenTree/plugin/base/action/api.py b/InvenTree/plugin/base/action/api.py index 735b46f65b..d2ee877fff 100644 --- a/InvenTree/plugin/base/action/api.py +++ b/InvenTree/plugin/base/action/api.py @@ -12,9 +12,7 @@ from plugin import registry class ActionPluginView(APIView): """Endpoint for running custom action plugins.""" - permission_classes = [ - permissions.IsAuthenticated, - ] + permission_classes = [permissions.IsAuthenticated] def post(self, request, *args, **kwargs): """This function checks if all required info was submitted and then performs a plugin_action or returns an error.""" @@ -23,9 +21,7 @@ class ActionPluginView(APIView): data = request.data.get('data', None) if action is None: - return Response({ - 'error': _("No action specified") - }) + return Response({'error': _("No action specified")}) action_plugins = registry.with_mixin('action') for plugin in action_plugins: @@ -34,7 +30,4 @@ class ActionPluginView(APIView): return Response(plugin.get_response(request.user, data=data)) # If we got to here, no matching action was found - return Response({ - 'error': _("No matching action found"), - "action": action, - }) + return Response({'error': _("No matching action found"), "action": action}) diff --git a/InvenTree/plugin/base/action/test_action.py b/InvenTree/plugin/base/action/test_action.py index 898bfb2647..322779cf8b 100644 --- a/InvenTree/plugin/base/action/test_action.py +++ b/InvenTree/plugin/base/action/test_action.py @@ -17,12 +17,15 @@ class ActionMixinTests(TestCase): Contains multiple sample plugins that are used in the tests """ + class SimplePlugin(ActionMixin, InvenTreePlugin): pass + self.plugin = SimplePlugin() class TestActionPlugin(ActionMixin, InvenTreePlugin): """An action plugin.""" + ACTION_NAME = 'abc123' def perform_action(self, user=None, data=None): @@ -53,21 +56,24 @@ class ActionMixinTests(TestCase): self.assertIsNone(self.plugin.perform_action()) self.assertEqual(self.plugin.get_result(), False) self.assertIsNone(self.plugin.get_info()) - self.assertEqual(self.plugin.get_response(), { - "action": '', - "result": False, - "info": None, - }) + self.assertEqual( + self.plugin.get_response(), {"action": '', "result": False, "info": None} + ) # overridden functions - self.assertEqual(self.action_plugin.perform_action(), self.ACTION_RETURN + 'action') + self.assertEqual( + self.action_plugin.perform_action(), self.ACTION_RETURN + 'action' + ) self.assertEqual(self.action_plugin.get_result(), self.ACTION_RETURN + 'result') self.assertEqual(self.action_plugin.get_info(), self.ACTION_RETURN + 'info') - self.assertEqual(self.action_plugin.get_response(), { - "action": 'abc123', - "result": self.ACTION_RETURN + 'result', - "info": self.ACTION_RETURN + 'info', - }) + self.assertEqual( + self.action_plugin.get_response(), + { + "action": 'abc123', + "result": self.ACTION_RETURN + 'result', + "info": self.ACTION_RETURN + 'info', + }, + ) class APITests(InvenTreeTestCase): @@ -78,15 +84,12 @@ class APITests(InvenTreeTestCase): # Test empty request response = self.client.post('/api/action/') self.assertEqual(response.status_code, 200) - self.assertEqual( - response.data, - {'error': 'No action specified'} - ) + self.assertEqual(response.data, {'error': 'No action specified'}) # Test non-exsisting action response = self.client.post('/api/action/', data={'action': "nonexsisting"}) self.assertEqual(response.status_code, 200) self.assertEqual( response.data, - {'error': 'No matching action found', 'action': 'nonexsisting'} + {'error': 'No matching action found', 'action': 'nonexsisting'}, ) diff --git a/InvenTree/plugin/base/barcodes/api.py b/InvenTree/plugin/base/barcodes/api.py index e46253bd62..e6021b7ebf 100644 --- a/InvenTree/plugin/base/barcodes/api.py +++ b/InvenTree/plugin/base/barcodes/api.py @@ -15,8 +15,7 @@ import order.models import stock.models from InvenTree.helpers import hash_barcode from plugin import registry -from plugin.builtin.barcodes.inventree_barcode import \ - InvenTreeInternalBarcodePlugin +from plugin.builtin.barcodes.inventree_barcode import InvenTreeInternalBarcodePlugin from users.models import RuleSet from . import serializers as barcode_serializers @@ -35,9 +34,7 @@ class BarcodeView(CreateAPIView): return None # Default permission classes (can be overridden) - permission_classes = [ - permissions.IsAuthenticated, - ] + permission_classes = [permissions.IsAuthenticated] def create(self, request, *args, **kwargs): """Handle create method - override default create""" @@ -60,7 +57,9 @@ class BarcodeView(CreateAPIView): kwargs: Any custom fields passed by the specific serializer """ - raise NotImplementedError(f"handle_barcode not implemented for {self.__class__}") + raise NotImplementedError( + f"handle_barcode not implemented for {self.__class__}" + ) def scan_barcode(self, barcode: str, request, **kwargs): """Perform a generic 'scan' of the provided barcode data. @@ -75,15 +74,17 @@ class BarcodeView(CreateAPIView): response = {} for current_plugin in plugins: - result = current_plugin.scan(barcode) if result is None: continue if "error" in result: - logger.info("%s.scan(...) returned an error: %s", - current_plugin.__class__.__name__, result["error"]) + logger.info( + "%s.scan(...) returned an error: %s", + current_plugin.__class__.__name__, + result["error"], + ) if not response: plugin = current_plugin response = result @@ -169,7 +170,6 @@ class BarcodeAssign(BarcodeView): valid_labels.append(label) if instance := kwargs.get(label, None): - # Check that the user has the required permission app_label = model._meta.app_label model_name = model._meta.model_name @@ -181,23 +181,18 @@ class BarcodeAssign(BarcodeView): "error": f"You do not have the required permissions for {table}" }) - instance.assign_barcode( - barcode_data=barcode, - barcode_hash=barcode_hash, - ) + instance.assign_barcode(barcode_data=barcode, barcode_hash=barcode_hash) return Response({ 'success': f"Assigned barcode to {label} instance", - label: { - 'pk': instance.pk, - }, + label: {'pk': instance.pk}, "barcode_data": barcode, "barcode_hash": barcode_hash, }) # If we got here, it means that no valid model types were provided raise ValidationError({ - 'error': f"Missing data: provide one of '{valid_labels}'", + 'error': f"Missing data: provide one of '{valid_labels}'" }) @@ -231,16 +226,14 @@ class BarcodeUnassign(BarcodeView): if len(matched_labels) > 1: raise ValidationError({ - 'error': f"Multiple conflicting fields: '{model_names}'", + 'error': f"Multiple conflicting fields: '{model_names}'" }) # At this stage, we know that we have received a single valid field for model in supported_models: - label = model.barcode_model_type() if instance := data.get(label, None): - # Check that the user has the required permission app_label = model._meta.app_label model_name = model._meta.model_name @@ -256,13 +249,11 @@ class BarcodeUnassign(BarcodeView): instance.unassign_barcode() return Response({ - 'success': f'Barcode unassigned from {label} instance', + 'success': f'Barcode unassigned from {label} instance' }) # If we get to this point, something has gone wrong! - raise ValidationError({ - 'error': 'Could not unassign barcode', - }) + raise ValidationError({'error': 'Could not unassign barcode'}) class BarcodePOAllocate(BarcodeView): @@ -275,13 +266,13 @@ class BarcodePOAllocate(BarcodeView): - A SupplierPart object """ - role_required = [ - 'purchase_order.add' - ] + role_required = ['purchase_order.add'] serializer_class = barcode_serializers.BarcodePOAllocateSerializer - def get_supplier_part(self, purchase_order, part=None, supplier_part=None, manufacturer_part=None): + def get_supplier_part( + self, purchase_order, part=None, supplier_part=None, manufacturer_part=None + ): """Return a single matching SupplierPart (or else raise an exception) Arguments: @@ -305,9 +296,7 @@ class BarcodePOAllocate(BarcodeView): supplier_parts = company.models.SupplierPart.objects.filter(supplier=supplier) if not part and not supplier_part and not manufacturer_part: - raise ValidationError({ - 'error': _('No matching part data found'), - }) + raise ValidationError({'error': _('No matching part data found')}) if part: if part_id := part.get('pk', None): @@ -319,12 +308,12 @@ class BarcodePOAllocate(BarcodeView): if manufacturer_part: if manufacturer_part_id := manufacturer_part.get('pk', None): - supplier_parts = supplier_parts.filter(manufacturer_part__pk=manufacturer_part_id) + supplier_parts = supplier_parts.filter( + manufacturer_part__pk=manufacturer_part_id + ) if supplier_parts.count() == 0: - raise ValidationError({ - "error": _("No matching supplier parts found") - }) + raise ValidationError({"error": _("No matching supplier parts found")}) if supplier_parts.count() > 1: raise ValidationError({ @@ -377,9 +366,7 @@ class BarcodePOReceive(BarcodeView): - location: The destination location for the received item (optional) """ - role_required = [ - 'purchase_order.add' - ] + role_required = ['purchase_order.add'] serializer_class = barcode_serializers.BarcodePOReceiveSerializer @@ -397,14 +384,11 @@ class BarcodePOReceive(BarcodeView): # Look for a barcode plugin which knows how to deal with this barcode plugin = None - response = { - "barcode_data": barcode, - "barcode_hash": hash_barcode(barcode) - } + response = {"barcode_data": barcode, "barcode_hash": hash_barcode(barcode)} - internal_barcode_plugin = next(filter( - lambda plugin: plugin.name == "InvenTreeBarcode", plugins - )) + internal_barcode_plugin = next( + filter(lambda plugin: plugin.name == "InvenTreeBarcode", plugins) + ) if result := internal_barcode_plugin.scan(barcode): if 'stockitem' in result: @@ -417,20 +401,19 @@ class BarcodePOReceive(BarcodeView): plugin_response = None for current_plugin in plugins: - result = current_plugin.scan_receive_item( - barcode, - request.user, - purchase_order=purchase_order, - location=location, + barcode, request.user, purchase_order=purchase_order, location=location ) if result is None: continue if "error" in result: - logger.info("%s.scan_receive_item(...) returned an error: %s", - current_plugin.__class__.__name__, result["error"]) + logger.info( + "%s.scan_receive_item(...) returned an error: %s", + current_plugin.__class__.__name__, + result["error"], + ) if not plugin_response: plugin = current_plugin plugin_response = result @@ -467,9 +450,7 @@ class BarcodeSOAllocate(BarcodeView): - Quantity """ - role_required = [ - 'sales_order.add', - ] + role_required = ['sales_order.add'] serializer_class = barcode_serializers.BarcodeSOAllocateSerializer @@ -488,20 +469,14 @@ class BarcodeSOAllocate(BarcodeView): # Find any matching line items for the stock item lines = order.models.SalesOrderLineItem.objects.filter( - order=sales_order, - part__in=parts, - shipped__lte=F('quantity'), + order=sales_order, part__in=parts, shipped__lte=F('quantity') ) if lines.count() > 1: - raise ValidationError({ - 'error': _('Multiple matching line items found'), - }) + raise ValidationError({'error': _('Multiple matching line items found')}) if lines.count() == 0: - raise ValidationError({ - 'error': _('No matching line item found'), - }) + raise ValidationError({'error': _('No matching line item found')}) return lines.first() @@ -513,14 +488,13 @@ class BarcodeSOAllocate(BarcodeView): if shipment := kwargs.get('shipment', None): if shipment.order != sales_order: raise ValidationError({ - 'error': _('Shipment does not match sales order'), + 'error': _('Shipment does not match sales order') }) return shipment shipments = order.models.SalesOrderShipment.objects.filter( - order=sales_order, - delivery_date=None + order=sales_order, delivery_date=None ) if shipments.count() == 1: @@ -579,7 +553,7 @@ class BarcodeSOAllocate(BarcodeView): 'sales_order': sales_order.pk if sales_order else None, 'line_item': line_item.pk if line_item else None, 'shipment': shipment.pk if shipment else None, - 'quantity': quantity + 'quantity': quantity, } if stock_item is not None and quantity is not None: @@ -590,10 +564,7 @@ class BarcodeSOAllocate(BarcodeView): # If we have sufficient information, we can allocate the stock item if all((x is not None for x in [line_item, sales_order, shipment, quantity])): order.models.SalesOrderAllocation.objects.create( - line=line_item, - shipment=shipment, - item=stock_item, - quantity=quantity, + line=line_item, shipment=shipment, item=stock_item, quantity=quantity ) response['success'] = _('Stock item allocated to sales order') @@ -609,19 +580,14 @@ class BarcodeSOAllocate(BarcodeView): barcode_api_urls = [ # Link a third-party barcode to an item (e.g. Part / StockItem / etc) path('link/', BarcodeAssign.as_view(), name='api-barcode-link'), - # Unlink a third-party barcode from an item path('unlink/', BarcodeUnassign.as_view(), name='api-barcode-unlink'), - # Receive a purchase order item by scanning its barcode path("po-receive/", BarcodePOReceive.as_view(), name="api-barcode-po-receive"), - # Allocate parts to a purchase order by scanning their barcode path("po-allocate/", BarcodePOAllocate.as_view(), name="api-barcode-po-allocate"), - # Allocate stock to a sales order by scanning barcode path("so-allocate/", BarcodeSOAllocate.as_view(), name="api-barcode-so-allocate"), - # Catch-all performs barcode 'scan' re_path(r'^.*$', BarcodeScan.as_view(), name='api-barcode-scan'), ] diff --git a/InvenTree/plugin/base/barcodes/mixins.py b/InvenTree/plugin/base/barcodes/mixins.py index a3511b4df1..7574d86abd 100644 --- a/InvenTree/plugin/base/barcodes/mixins.py +++ b/InvenTree/plugin/base/barcodes/mixins.py @@ -124,7 +124,9 @@ class SupplierBarcodeMixin(BarcodeMixin): A dict object containing the barcode fields. """ - raise NotImplementedError("extract_barcode_fields must be implemented by each plugin") + raise NotImplementedError( + "extract_barcode_fields must be implemented by each plugin" + ) def scan(self, barcode_data): """Try to match a supplier barcode to a supplier part.""" @@ -155,9 +157,7 @@ class SupplierBarcodeMixin(BarcodeMixin): "web_url": supplier_part.get_absolute_url(), } - return { - SupplierPart.barcode_model_type(): data - } + return {SupplierPart.barcode_model_type(): data} def scan_receive_item(self, barcode_data, user, purchase_order=None, location=None): """Try to scan a supplier barcode to receive a purchase order item.""" @@ -195,7 +195,9 @@ class SupplierBarcodeMixin(BarcodeMixin): order = self.customer_order_number or self.supplier_order_number if len(matching_orders) > 1: - return {"error": _(f"Found multiple purchase orders matching '{order}'")} + return { + "error": _(f"Found multiple purchase orders matching '{order}'") + } if len(matching_orders) == 0: return {"error": _(f"No matching purchase order for '{order}'")} @@ -225,19 +227,21 @@ class SupplierBarcodeMixin(BarcodeMixin): return None if supplier_pk := self.get_setting("SUPPLIER_ID"): - if (supplier := Company.objects.get(pk=supplier_pk)): + if supplier := Company.objects.get(pk=supplier_pk): return supplier else: logger.error( "No company with pk %d (set \"SUPPLIER_ID\" setting to a valid value)", - supplier_pk + supplier_pk, ) return None if not (supplier_name := getattr(self, "DEFAULT_SUPPLIER_NAME", None)): return None - suppliers = Company.objects.filter(name__icontains=supplier_name, is_supplier=True) + suppliers = Company.objects.filter( + name__icontains=supplier_name, is_supplier=True + ) if len(suppliers) != 1: return None @@ -297,20 +301,22 @@ class SupplierBarcodeMixin(BarcodeMixin): for field in fields: for identifier, field_name in cls.ecia_field_map().items(): if field.startswith(identifier): - barcode_fields[field_name] = field[len(identifier):] + barcode_fields[field_name] = field[len(identifier) :] break return barcode_fields @staticmethod - def split_fields(barcode_data: str, delimiter: str = ',', header: str = '', trailer: str = '') -> list[str]: + def split_fields( + barcode_data: str, delimiter: str = ',', header: str = '', trailer: str = '' + ) -> list[str]: """Generic method for splitting barcode data into separate fields""" if header and barcode_data.startswith(header): - barcode_data = barcode_data[len(header):] + barcode_data = barcode_data[len(header) :] if trailer and barcode_data.endswith(trailer): - barcode_data = barcode_data[:-len(trailer)] + barcode_data = barcode_data[: -len(trailer)] return barcode_data.split(delimiter) @@ -318,10 +324,10 @@ class SupplierBarcodeMixin(BarcodeMixin): def parse_isoiec_15434_barcode2d(barcode_data: str) -> list[str]: """Parse a ISO/IEC 15434 barcode, returning the split data section.""" - OLD_MOUSER_HEADER = ">[)>06\x1D" - HEADER = "[)>\x1E06\x1D" - TRAILER = "\x1E\x04" - DELIMITER = "\x1D" + OLD_MOUSER_HEADER = ">[)>06\x1d" + HEADER = "[)>\x1e06\x1d" + TRAILER = "\x1e\x04" + DELIMITER = "\x1d" # Some old mouser barcodes start with this messed up header if barcode_data.startswith(OLD_MOUSER_HEADER): @@ -332,14 +338,13 @@ class SupplierBarcodeMixin(BarcodeMixin): return return SupplierBarcodeMixin.split_fields( - barcode_data, - delimiter=DELIMITER, - header=HEADER, - trailer=TRAILER, + barcode_data, delimiter=DELIMITER, header=HEADER, trailer=TRAILER ) @staticmethod - def get_purchase_orders(customer_order_number, supplier_order_number, supplier: Company = None): + def get_purchase_orders( + customer_order_number, supplier_order_number, supplier: Company = None + ): """Attempt to find a purchase order from the extracted customer and supplier order numbers""" orders = PurchaseOrder.objects.filter(status=PurchaseOrderStatus.PLACED.value) @@ -389,12 +394,12 @@ class SupplierBarcodeMixin(BarcodeMixin): @staticmethod def receive_purchase_order_item( - supplier_part: SupplierPart, - user: User, - quantity: Decimal | str = None, - purchase_order: PurchaseOrder = None, - location: StockLocation = None, - barcode: str = None, + supplier_part: SupplierPart, + user: User, + quantity: Decimal | str = None, + purchase_order: PurchaseOrder = None, + location: StockLocation = None, + barcode: str = None, ) -> dict: """Try to receive a purchase order item. @@ -414,7 +419,8 @@ class SupplierBarcodeMixin(BarcodeMixin): # find incomplete line_items that match the supplier_part line_items = purchase_order.lines.filter( - part=supplier_part.pk, quantity__gt=F("received")) + part=supplier_part.pk, quantity__gt=F("received") + ) if len(line_items) == 1 or not quantity: line_item = line_items[0] else: @@ -451,10 +457,7 @@ class SupplierBarcodeMixin(BarcodeMixin): no_stock_locations = True response = { - "lineitem": { - "pk": line_item.pk, - "purchase_order": purchase_order.pk, - } + "lineitem": {"pk": line_item.pk, "purchase_order": purchase_order.pk} } if quantity: @@ -466,15 +469,13 @@ class SupplierBarcodeMixin(BarcodeMixin): # -> return the line_item found, so the client can gather the missing # information and complete the action with an 'api-po-receive' call if not quantity or (not location and not no_stock_locations): - response["action_required"] = _("Further information required to receive line item") + response["action_required"] = _( + "Further information required to receive line item" + ) return response purchase_order.receive_line_item( - line_item, - location, - quantity, - user, - barcode=barcode, + line_item, location, quantity, user, barcode=barcode ) response["success"] = _("Received purchase order line item") diff --git a/InvenTree/plugin/base/barcodes/serializers.py b/InvenTree/plugin/base/barcodes/serializers.py index ef0b17d3d0..52c4f83ecb 100644 --- a/InvenTree/plugin/base/barcodes/serializers.py +++ b/InvenTree/plugin/base/barcodes/serializers.py @@ -8,8 +8,7 @@ from rest_framework import serializers import order.models import stock.models from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus -from plugin.builtin.barcodes.inventree_barcode import \ - InvenTreeInternalBarcodePlugin +from plugin.builtin.barcodes.inventree_barcode import InvenTreeInternalBarcodePlugin class BarcodeSerializer(serializers.Serializer): @@ -18,7 +17,8 @@ class BarcodeSerializer(serializers.Serializer): MAX_BARCODE_LENGTH = 4095 barcode = serializers.CharField( - required=True, help_text=_('Scanned barcode data'), + required=True, + help_text=_('Scanned barcode data'), max_length=MAX_BARCODE_LENGTH, ) @@ -32,17 +32,21 @@ class BarcodeAssignMixin(serializers.Serializer): super().__init__(*args, **kwargs) for model in InvenTreeInternalBarcodePlugin.get_supported_barcode_models(): - self.fields[model.barcode_model_type()] = serializers.PrimaryKeyRelatedField( - queryset=model.objects.all(), - required=False, allow_null=True, - label=model._meta.verbose_name, + self.fields[model.barcode_model_type()] = ( + serializers.PrimaryKeyRelatedField( + queryset=model.objects.all(), + required=False, + allow_null=True, + label=model._meta.verbose_name, + ) ) @staticmethod def get_model_fields(): """Return a list of model fields""" fields = [ - model.barcode_model_type() for model in InvenTreeInternalBarcodePlugin.get_supported_barcode_models() + model.barcode_model_type() + for model in InvenTreeInternalBarcodePlugin.get_supported_barcode_models() ] return fields @@ -54,10 +58,7 @@ class BarcodeAssignSerializer(BarcodeAssignMixin, BarcodeSerializer): class Meta: """Meta class for BarcodeAssignSerializer""" - fields = [ - 'barcode', - *BarcodeAssignMixin.get_model_fields() - ] + fields = ['barcode', *BarcodeAssignMixin.get_model_fields()] class BarcodeUnassignSerializer(BarcodeAssignMixin): @@ -101,7 +102,8 @@ class BarcodePOReceiveSerializer(BarcodeSerializer): purchase_order = serializers.PrimaryKeyRelatedField( queryset=order.models.PurchaseOrder.objects.all(), - required=False, allow_null=True, + required=False, + allow_null=True, help_text=_('PurchaseOrder to receive items against'), ) @@ -115,7 +117,8 @@ class BarcodePOReceiveSerializer(BarcodeSerializer): location = serializers.PrimaryKeyRelatedField( queryset=stock.models.StockLocation.objects.all(), - required=False, allow_null=True, + required=False, + allow_null=True, help_text=_('Location to receive items into'), ) @@ -150,13 +153,15 @@ class BarcodeSOAllocateSerializer(BarcodeSerializer): line = serializers.PrimaryKeyRelatedField( queryset=order.models.SalesOrderLineItem.objects.all(), - required=False, allow_null=True, + required=False, + allow_null=True, help_text=_('Sales order line item to allocate items against'), ) shipment = serializers.PrimaryKeyRelatedField( queryset=order.models.SalesOrderShipment.objects.all(), - required=False, allow_null=True, + required=False, + allow_null=True, help_text=_('Sales order shipment to allocate items against'), ) @@ -169,6 +174,5 @@ class BarcodeSOAllocateSerializer(BarcodeSerializer): return shipment quantity = serializers.IntegerField( - required=False, - help_text=_('Quantity to allocate'), + required=False, help_text=_('Quantity to allocate') ) diff --git a/InvenTree/plugin/base/barcodes/test_barcode.py b/InvenTree/plugin/base/barcodes/test_barcode.py index b828e12848..c75229d279 100644 --- a/InvenTree/plugin/base/barcodes/test_barcode.py +++ b/InvenTree/plugin/base/barcodes/test_barcode.py @@ -12,12 +12,7 @@ from stock.models import StockItem class BarcodeAPITest(InvenTreeAPITestCase): """Tests for barcode api.""" - fixtures = [ - 'category', - 'part', - 'location', - 'stock' - ] + fixtures = ['category', 'part', 'location', 'stock'] def setUp(self): """Setup for all tests.""" @@ -29,7 +24,12 @@ class BarcodeAPITest(InvenTreeAPITestCase): def postBarcode(self, url, barcode, expected_code=None): """Post barcode and return results.""" - return self.post(url, format='json', data={'barcode': str(barcode)}, expected_code=expected_code) + return self.post( + url, + format='json', + data={'barcode': str(barcode)}, + expected_code=expected_code, + ) def test_invalid(self): """Test that invalid requests fail.""" @@ -38,8 +38,15 @@ class BarcodeAPITest(InvenTreeAPITestCase): # test wrong assign urls self.post(self.assign_url, format='json', data={}, expected_code=400) - self.post(self.assign_url, format='json', data={'barcode': '123'}, expected_code=400) - self.post(self.assign_url, format='json', data={'barcode': '123', 'stockitem': '123'}, expected_code=400) + self.post( + self.assign_url, format='json', data={'barcode': '123'}, expected_code=400 + ) + self.post( + self.assign_url, + format='json', + data={'barcode': '123', 'stockitem': '123'}, + expected_code=400, + ) def test_empty(self): """Test an empty barcode scan. @@ -59,11 +66,7 @@ class BarcodeAPITest(InvenTreeAPITestCase): part = Part.objects.first() response = self.post( - self.scan_url, - { - 'barcode': f'{{"part": {part.pk}}}', - }, - expected_code=200 + self.scan_url, {'barcode': f'{{"part": {part.pk}}}'}, expected_code=200 ) self.assertIn('part', response.data) @@ -73,11 +76,7 @@ class BarcodeAPITest(InvenTreeAPITestCase): def test_invalid_part(self): """Test response for invalid part.""" response = self.post( - self.scan_url, - { - 'barcode': '{"part": 999999999}' - }, - expected_code=400 + self.scan_url, {'barcode': '{"part": 999999999}'}, expected_code=400 ) self.assertIn('error', response.data) @@ -88,11 +87,7 @@ class BarcodeAPITest(InvenTreeAPITestCase): item = StockItem.objects.first() response = self.post( - self.scan_url, - { - 'barcode': item.format_barcode(), - }, - expected_code=200 + self.scan_url, {'barcode': item.format_barcode()}, expected_code=200 ) self.assertIn('stockitem', response.data) @@ -102,11 +97,7 @@ class BarcodeAPITest(InvenTreeAPITestCase): def test_invalid_item(self): """Test response for invalid stock item.""" response = self.post( - self.scan_url, - { - 'barcode': '{"stockitem": 999999999}' - }, - expected_code=400 + self.scan_url, {'barcode': '{"stockitem": 999999999}'}, expected_code=400 ) self.assertIn('error', response.data) @@ -114,11 +105,7 @@ class BarcodeAPITest(InvenTreeAPITestCase): def test_find_location(self): """Test that we can lookup a stock location based on ID.""" response = self.post( - self.scan_url, - { - 'barcode': '{"stocklocation": 1}', - }, - expected_code=200 + self.scan_url, {'barcode': '{"stocklocation": 1}'}, expected_code=200 ) self.assertIn('stocklocation', response.data) @@ -129,10 +116,8 @@ class BarcodeAPITest(InvenTreeAPITestCase): """Test response for an invalid location.""" response = self.post( self.scan_url, - { - 'barcode': '{"stocklocation": 999999999}' - }, - expected_code=400 + {'barcode': '{"stocklocation": 999999999}'}, + expected_code=400, ) self.assertIn('error', response.data) @@ -155,7 +140,9 @@ class BarcodeAPITest(InvenTreeAPITestCase): """Test that a barcode is generated with a scan.""" item = StockItem.objects.get(pk=522) - response = self.postBarcode(self.scan_url, item.format_barcode(), expected_code=200) + response = self.postBarcode( + self.scan_url, item.format_barcode(), expected_code=200 + ) data = response.data self.assertIn('stockitem', data) @@ -175,12 +162,10 @@ class BarcodeAPITest(InvenTreeAPITestCase): barcode_data = 'A-TEST-BARCODE-STRING' response = self.post( - self.assign_url, format='json', - data={ - 'barcode': barcode_data, - 'stockitem': item.pk - }, - expected_code=200 + self.assign_url, + format='json', + data={'barcode': barcode_data, 'stockitem': item.pk}, + expected_code=200, ) data = response.data @@ -197,12 +182,10 @@ class BarcodeAPITest(InvenTreeAPITestCase): # Ensure that the same barcode hash cannot be assigned to a different stock item! response = self.post( - self.assign_url, format='json', - data={ - 'barcode': barcode_data, - 'stockitem': 521 - }, - expected_code=400 + self.assign_url, + format='json', + data={'barcode': barcode_data, 'stockitem': 521}, + expected_code=400, ) self.assertIn('error', response.data) @@ -210,11 +193,7 @@ class BarcodeAPITest(InvenTreeAPITestCase): # Check that we can now unassign a barcode response = self.post( - self.unassign_url, - { - 'stockitem': item.pk, - }, - expected_code=200 + self.unassign_url, {'stockitem': item.pk}, expected_code=200 ) item.refresh_from_db() @@ -222,11 +201,7 @@ class BarcodeAPITest(InvenTreeAPITestCase): # Check that the 'unassign' endpoint fails if the stockitem is invalid response = self.post( - self.unassign_url, - { - 'stockitem': 999999999, - }, - expected_code=400 + self.unassign_url, {'stockitem': 999999999}, expected_code=400 ) def test_unassign_endpoint(self): @@ -236,13 +211,7 @@ class BarcodeAPITest(InvenTreeAPITestCase): # Invalid key should fail for k in invalid_keys: - response = self.post( - self.unassign_url, - { - k: 123 - }, - expected_code=400 - ) + response = self.post(self.unassign_url, {k: 123}, expected_code=400) self.assertIn("Missing data: Provide one of", str(response.data['error'])) @@ -250,13 +219,7 @@ class BarcodeAPITest(InvenTreeAPITestCase): # Valid key but invalid pk should fail for k in valid_keys: - response = self.post( - self.unassign_url, - { - k: 999999999 - }, - expected_code=400 - ) + response = self.post(self.unassign_url, {k: 999999999}, expected_code=400) self.assertIn("object does not exist", str(response.data[k])) @@ -264,13 +227,7 @@ class BarcodeAPITest(InvenTreeAPITestCase): class SOAllocateTest(InvenTreeAPITestCase): """Unit tests for the barcode endpoint for allocating items to a sales order""" - fixtures = [ - 'category', - 'company', - 'part', - 'location', - 'stock', - ] + fixtures = ['category', 'company', 'part', 'location', 'stock'] @classmethod def setUpTestData(cls): @@ -285,22 +242,15 @@ class SOAllocateTest(InvenTreeAPITestCase): cls.part = Part.objects.filter(salable=True).first() # Make a stock item - cls.stock_item = StockItem.objects.create( - part=cls.part, - quantity=100 - ) + cls.stock_item = StockItem.objects.create(part=cls.part, quantity=100) cls.stock_item.assign_barcode(barcode_data='barcode') # Find a customer - cls.customer = company.models.Company.objects.filter( - is_customer=True - ).first() + cls.customer = company.models.Company.objects.filter(is_customer=True).first() # Create a sales order - cls.sales_order = order.models.SalesOrder.objects.create( - customer=cls.customer - ) + cls.sales_order = order.models.SalesOrder.objects.create(customer=cls.customer) # Create a shipment cls.shipment = order.models.SalesOrderShipment.objects.create( @@ -309,9 +259,7 @@ class SOAllocateTest(InvenTreeAPITestCase): # Create a line item cls.line_item = order.models.SalesOrderLineItem.objects.create( - order=cls.sales_order, - part=cls.part, - quantity=10, + order=cls.sales_order, part=cls.part, quantity=10 ) def setUp(self): @@ -321,15 +269,10 @@ class SOAllocateTest(InvenTreeAPITestCase): def postBarcode(self, barcode, expected_code=None, **kwargs): """Post barcode and return results.""" - data = { - 'barcode': barcode, - **kwargs - } + data = {'barcode': barcode, **kwargs} response = self.post( - reverse('api-barcode-so-allocate'), - data=data, - expected_code=expected_code, + reverse('api-barcode-so-allocate'), data=data, expected_code=expected_code ) return response.data @@ -346,11 +289,7 @@ class SOAllocateTest(InvenTreeAPITestCase): """Test when an invalid sales order is provided""" # Test with an invalid sales order ID - result = self.postBarcode( - '', - sales_order=999999999, - expected_code=400 - ) + result = self.postBarcode('', sales_order=999999999, expected_code=400) self.assertIn('object does not exist', str(result['sales_order'])) @@ -359,9 +298,7 @@ class SOAllocateTest(InvenTreeAPITestCase): # Test with an invalid barcode result = self.postBarcode( - '123456789', - sales_order=self.sales_order.pk, - expected_code=400 + '123456789', sales_order=self.sales_order.pk, expected_code=400 ) self.assertIn('No match found for barcode', str(result['error'])) @@ -371,9 +308,7 @@ class SOAllocateTest(InvenTreeAPITestCase): item.assign_barcode(barcode_data='123456789') result = self.postBarcode( - '123456789', - sales_order=self.sales_order.pk, - expected_code=400 + '123456789', sales_order=self.sales_order.pk, expected_code=400 ) self.assertIn('No matching line item found', str(result['error'])) @@ -382,9 +317,7 @@ class SOAllocateTest(InvenTreeAPITestCase): item.part.assign_barcode(barcode_data='abcde') result = self.postBarcode( - 'abcde', - sales_order=self.sales_order.pk, - expected_code=400 + 'abcde', sales_order=self.sales_order.pk, expected_code=400 ) self.assertIn('does not match an existing stock item', str(result['error'])) @@ -393,9 +326,7 @@ class SOAllocateTest(InvenTreeAPITestCase): """Test data submission""" # Create a shipment for a different order - other_order = order.models.SalesOrder.objects.create( - customer=self.customer - ) + other_order = order.models.SalesOrder.objects.create(customer=self.customer) other_shipment = order.models.SalesOrderShipment.objects.create( order=other_order @@ -406,7 +337,7 @@ class SOAllocateTest(InvenTreeAPITestCase): self.stock_item.format_barcode(), sales_order=self.sales_order.pk, shipment=other_shipment.pk, - expected_code=400 + expected_code=400, ) self.assertIn('Shipment does not match sales order', str(response['error'])) @@ -418,7 +349,7 @@ class SOAllocateTest(InvenTreeAPITestCase): response = self.postBarcode( self.stock_item.format_barcode(), sales_order=self.sales_order.pk, - expected_code=200 + expected_code=200, ) # Check that the right data has been extracted diff --git a/InvenTree/plugin/base/event/events.py b/InvenTree/plugin/base/event/events.py index 01502b528c..c1699fbeb0 100644 --- a/InvenTree/plugin/base/event/events.py +++ b/InvenTree/plugin/base/event/events.py @@ -31,7 +31,10 @@ def trigger_event(event, *args, **kwargs): return # Make sure the database can be accessed and is not being tested rn - if not canAppAccessDatabase(allow_shell=True) and not settings.PLUGIN_TESTING_EVENTS: + if ( + not canAppAccessDatabase(allow_shell=True) + and not settings.PLUGIN_TESTING_EVENTS + ): logger.debug("Ignoring triggered event '%s' - database not ready", event) return @@ -41,12 +44,7 @@ def trigger_event(event, *args, **kwargs): if 'force_async' not in kwargs and not settings.PLUGIN_TESTING_EVENTS: kwargs['force_async'] = True - offload_task( - register_event, - event, - *args, - **kwargs - ) + offload_task(register_event, event, *args, **kwargs) def register_event(event, *args, **kwargs): @@ -61,9 +59,7 @@ def register_event(event, *args, **kwargs): # Determine if there are any plugins which are interested in responding if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_EVENTS'): - with transaction.atomic(): - for slug, plugin in registry.plugins.items(): if not plugin.mixin_enabled('events'): continue @@ -84,13 +80,7 @@ def register_event(event, *args, **kwargs): kwargs['force_async'] = True # Offload a separate task for each plugin - offload_task( - process_event, - slug, - event, - *args, - **kwargs - ) + offload_task(process_event, slug, event, *args, **kwargs) def process_event(plugin_slug, event, *args, **kwargs): @@ -172,17 +162,9 @@ def after_save(sender, instance, created, **kwargs): return if created: - trigger_event( - f'{table}.created', - id=instance.id, - model=sender.__name__, - ) + trigger_event(f'{table}.created', id=instance.id, model=sender.__name__) else: - trigger_event( - f'{table}.saved', - id=instance.id, - model=sender.__name__, - ) + trigger_event(f'{table}.saved', id=instance.id, model=sender.__name__) @receiver(post_delete) @@ -193,7 +175,4 @@ def after_delete(sender, instance, **kwargs): if not allow_table_event(table): return - trigger_event( - f'{table}.deleted', - model=sender.__name__, - ) + trigger_event(f'{table}.deleted', model=sender.__name__) diff --git a/InvenTree/plugin/base/integration/APICallMixin.py b/InvenTree/plugin/base/integration/APICallMixin.py index 1dcef4dc61..11bead3327 100644 --- a/InvenTree/plugin/base/integration/APICallMixin.py +++ b/InvenTree/plugin/base/integration/APICallMixin.py @@ -1,6 +1,5 @@ """Mixin class for making calls to an external API""" - import json as json_pkg import logging @@ -56,6 +55,7 @@ class APICallMixin: return self.api_call('api/users/2') ``` """ + API_METHOD = 'https' API_URL_SETTING = None API_TOKEN_SETTING = None @@ -64,6 +64,7 @@ class APICallMixin: class MixinMeta: """Meta options for this mixin.""" + MIXIN_NAME = 'API calls' def __init__(self): @@ -109,7 +110,17 @@ class APICallMixin: groups.append(f'{key}={",".join([str(a) for a in val])}') return f'?{"&".join(groups)}' - def api_call(self, endpoint: str, method: str = 'GET', url_args: dict = None, data=None, json=None, headers: dict = None, simple_response: bool = True, endpoint_is_url: bool = False): + def api_call( + self, + endpoint: str, + method: str = 'GET', + url_args: dict = None, + data=None, + json=None, + headers: dict = None, + simple_response: bool = True, + endpoint_is_url: bool = False, + ): """Do an API call. Simplest call example: @@ -140,17 +151,13 @@ class APICallMixin: if endpoint_is_url: url = endpoint else: - if endpoint.startswith('/'): endpoint = endpoint[1:] url = f'{self.api_url}/{endpoint}' # build kwargs for call - kwargs = { - 'url': url, - 'headers': headers, - } + kwargs = {'url': url, 'headers': headers} if data and json: raise ValueError('You can either pass `data` or `json` to this function.') diff --git a/InvenTree/plugin/base/integration/AppMixin.py b/InvenTree/plugin/base/integration/AppMixin.py index 700f94a98e..196b941253 100644 --- a/InvenTree/plugin/base/integration/AppMixin.py +++ b/InvenTree/plugin/base/integration/AppMixin.py @@ -1,4 +1,5 @@ """Plugin mixin class for AppMixin.""" + import logging from importlib import reload from pathlib import Path @@ -26,7 +27,9 @@ class AppMixin: self.add_mixin('app', 'has_app', __class__) @classmethod - def _activate_mixin(cls, registry, plugins, force_reload=False, full_reload: bool = False): + def _activate_mixin( + cls, registry, plugins, force_reload=False, full_reload: bool = False + ): """Activate AppMixin plugins - add custom apps and reload. Args: @@ -37,7 +40,9 @@ class AppMixin: """ from common.models import InvenTreeSetting - if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP'): + if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting( + 'ENABLE_PLUGINS_APP' + ): logger.info('Registering IntegrationPlugin apps') apps_changed = False @@ -177,7 +182,7 @@ class AppMixin: return plugin_path -# endregion + # endregion @property def has_app(self): diff --git a/InvenTree/plugin/base/integration/CurrencyExchangeMixin.py b/InvenTree/plugin/base/integration/CurrencyExchangeMixin.py index 3ed67f5707..613b54d8ad 100644 --- a/InvenTree/plugin/base/integration/CurrencyExchangeMixin.py +++ b/InvenTree/plugin/base/integration/CurrencyExchangeMixin.py @@ -1,6 +1,5 @@ """Plugin mixin class for supporting currency exchange data""" - from plugin.helpers import MixinNotImplementedError @@ -39,4 +38,6 @@ class CurrencyExchangeMixin: Raises: Can raise any exception if the update fails """ - raise MixinNotImplementedError("Plugin must implement update_exchange_rates method") + raise MixinNotImplementedError( + "Plugin must implement update_exchange_rates method" + ) diff --git a/InvenTree/plugin/base/integration/ScheduleMixin.py b/InvenTree/plugin/base/integration/ScheduleMixin.py index 1db3a339de..edde7a2e4e 100644 --- a/InvenTree/plugin/base/integration/ScheduleMixin.py +++ b/InvenTree/plugin/base/integration/ScheduleMixin.py @@ -1,4 +1,5 @@ """Plugin mixin class for ScheduleMixin.""" + import logging from django.conf import settings @@ -64,12 +65,11 @@ class ScheduleMixin: # List of tasks we have activated task_keys = [] - if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_SCHEDULE'): - + if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting( + 'ENABLE_PLUGINS_SCHEDULE' + ): for _key, plugin in plugins: - if plugin.mixin_enabled('schedule'): - if plugin.is_active(): # Only active tasks for plugins which are enabled plugin.register_tasks() @@ -83,7 +83,9 @@ class ScheduleMixin: try: from django_q.models import Schedule - scheduled_plugin_tasks = Schedule.objects.filter(name__istartswith="plugin.") + scheduled_plugin_tasks = Schedule.objects.filter( + name__istartswith="plugin." + ) deleted_count = 0 @@ -93,7 +95,9 @@ class ScheduleMixin: deleted_count += 1 if deleted_count > 0: - logger.info("Removed %s old scheduled tasks", deleted_count) # pragma: no cover + logger.info( + "Removed %s old scheduled tasks", deleted_count + ) # pragma: no cover except (ProgrammingError, OperationalError): # Database might not yet be ready logger.warning("activate_integration_schedule failed, database not ready") @@ -116,21 +120,28 @@ class ScheduleMixin: raise MixinImplementationError("SCHEDULED_TASKS not defined") for key, task in self.scheduled_tasks.items(): - if 'func' not in task: - raise MixinImplementationError(f"Task '{key}' is missing 'func' parameter") + raise MixinImplementationError( + f"Task '{key}' is missing 'func' parameter" + ) if 'schedule' not in task: - raise MixinImplementationError(f"Task '{key}' is missing 'schedule' parameter") + raise MixinImplementationError( + f"Task '{key}' is missing 'schedule' parameter" + ) schedule = task['schedule'].upper().strip() if schedule not in self.ALLOWABLE_SCHEDULE_TYPES: - raise MixinImplementationError(f"Task '{key}': Schedule '{schedule}' is not a valid option") + raise MixinImplementationError( + f"Task '{key}': Schedule '{schedule}' is not a valid option" + ) # If 'minutes' is selected, it must be provided! if schedule == 'I' and 'minutes' not in task: - raise MixinImplementationError(f"Task '{key}' is missing 'minutes' parameter") + raise MixinImplementationError( + f"Task '{key}' is missing 'minutes' parameter" + ) def get_task_name(self, key): """Task name for key.""" @@ -149,7 +160,6 @@ class ScheduleMixin: from django_q.models import Schedule for key, task in self.scheduled_tasks.items(): - task_name = self.get_task_name(key) obj = { @@ -192,7 +202,6 @@ class ScheduleMixin: from django_q.models import Schedule for key, _ in self.scheduled_tasks.items(): - task_name = self.get_task_name(key) try: diff --git a/InvenTree/plugin/base/integration/SettingsMixin.py b/InvenTree/plugin/base/integration/SettingsMixin.py index 21847c2009..c258f80103 100644 --- a/InvenTree/plugin/base/integration/SettingsMixin.py +++ b/InvenTree/plugin/base/integration/SettingsMixin.py @@ -1,4 +1,5 @@ """Plugin mixin class for SettingsMixin.""" + import logging from typing import TYPE_CHECKING, Dict @@ -10,8 +11,10 @@ logger = logging.getLogger('inventree') if TYPE_CHECKING: from common.models import SettingsKeyType else: + class SettingsKeyType: """Dummy class, so that python throws no error""" + pass @@ -22,6 +25,7 @@ class SettingsMixin: class MixinMeta: """Meta for mixin.""" + MIXIN_NAME = 'Settings' def __init__(self): @@ -74,7 +78,9 @@ class SettingsMixin: from plugin.models import PluginConfig, PluginSetting try: - plugin, _ = PluginConfig.objects.get_or_create(key=self.plugin_slug(), name=self.plugin_name()) + plugin, _ = PluginConfig.objects.get_or_create( + key=self.plugin_slug(), name=self.plugin_name() + ) except (OperationalError, ProgrammingError): # pragma: no cover plugin = None @@ -96,4 +102,6 @@ class SettingsMixin: """ from plugin.models import PluginSetting - return PluginSetting.check_all_settings(settings_definition=self.settings, plugin=self.plugin_config()) + return PluginSetting.check_all_settings( + settings_definition=self.settings, plugin=self.plugin_config() + ) diff --git a/InvenTree/plugin/base/integration/UrlsMixin.py b/InvenTree/plugin/base/integration/UrlsMixin.py index 67f87455d1..7a5972c0d8 100644 --- a/InvenTree/plugin/base/integration/UrlsMixin.py +++ b/InvenTree/plugin/base/integration/UrlsMixin.py @@ -1,4 +1,5 @@ """Plugin mixin class for UrlsMixin.""" + import logging from django.conf import settings @@ -24,7 +25,9 @@ class UrlsMixin: self.urls = self.setup_urls() @classmethod - def _activate_mixin(cls, registry, plugins, force_reload=False, full_reload: bool = False): + def _activate_mixin( + cls, registry, plugins, force_reload=False, full_reload: bool = False + ): """Activate UrlsMixin plugins - add custom urls . Args: @@ -34,7 +37,10 @@ class UrlsMixin: full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False. """ from common.models import InvenTreeSetting - if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_URL'): + + if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting( + 'ENABLE_PLUGINS_URL' + ): logger.info('Registering UrlsMixin Plugin') urls_changed = False # check whether an activated plugin extends UrlsMixin @@ -64,7 +70,9 @@ class UrlsMixin: def urlpatterns(self): """Urlpatterns for this plugin.""" if self.has_urls: - return re_path(f'^{self.slug}/', include((self.urls, self.slug)), name=self.slug) + return re_path( + f'^{self.slug}/', include((self.urls, self.slug)), name=self.slug + ) return None @property diff --git a/InvenTree/plugin/base/integration/ValidationMixin.py b/InvenTree/plugin/base/integration/ValidationMixin.py index caf015aa4c..0376984ace 100644 --- a/InvenTree/plugin/base/integration/ValidationMixin.py +++ b/InvenTree/plugin/base/integration/ValidationMixin.py @@ -1,6 +1,5 @@ """Validation mixin class definition""" - import part.models import stock.models @@ -33,6 +32,7 @@ class ValidationMixin: class MixinMeta: """Metaclass for this mixin""" + MIXIN_NAME = "Validation" def __init__(self): diff --git a/InvenTree/plugin/base/integration/mixins.py b/InvenTree/plugin/base/integration/mixins.py index cc6276a5a0..53e234637e 100644 --- a/InvenTree/plugin/base/integration/mixins.py +++ b/InvenTree/plugin/base/integration/mixins.py @@ -3,8 +3,7 @@ import logging from InvenTree.helpers import generateTestKey -from plugin.helpers import (MixinNotImplementedError, render_template, - render_text) +from plugin.helpers import MixinNotImplementedError, render_template, render_text logger = logging.getLogger('inventree') @@ -32,7 +31,7 @@ class NavigationMixin: if nav_links: # check if needed values are configured for link in nav_links: - if False in [a in link for a in ('link', 'name', )]: + if False in [a in link for a in ('link', 'name')]: raise MixinNotImplementedError('Wrong Link definition', link) return nav_links @@ -111,7 +110,9 @@ class PanelMixin: def get_custom_panels(self, view, request): """This method *must* be implemented by the plugin class.""" - raise MixinNotImplementedError(f"{__class__} is missing the 'get_custom_panels' method") + raise MixinNotImplementedError( + f"{__class__} is missing the 'get_custom_panels' method" + ) def get_panel_context(self, view, request, context): """Build the context data to be used for template rendering. @@ -152,7 +153,6 @@ class PanelMixin: custom_panels = self.get_custom_panels(view, request) or [] for panel in custom_panels: - content_template = panel.get('content_template', None) javascript_template = panel.get('javascript_template', None) @@ -174,7 +174,10 @@ class PanelMixin: required_keys = ['title', 'content'] if any(key not in panel for key in required_keys): - logger.warning("Custom panel for plugin %s is missing a required parameter", __class__) + logger.warning( + "Custom panel for plugin %s is missing a required parameter", + __class__, + ) continue # Add some information on this plugin @@ -207,4 +210,6 @@ class SettingsContentMixin: def get_settings_content(self, view, request): """This method *must* be implemented by the plugin class.""" - raise MixinNotImplementedError(f"{__class__} is missing the 'get_settings_content' method") + raise MixinNotImplementedError( + f"{__class__} is missing the 'get_settings_content' method" + ) diff --git a/InvenTree/plugin/base/integration/test_mixins.py b/InvenTree/plugin/base/integration/test_mixins.py index fde9df5352..70bd7d41b3 100644 --- a/InvenTree/plugin/base/integration/test_mixins.py +++ b/InvenTree/plugin/base/integration/test_mixins.py @@ -12,8 +12,13 @@ from InvenTree.unit_test import InvenTreeTestCase from plugin import InvenTreePlugin from plugin.base.integration.mixins import PanelMixin from plugin.helpers import MixinNotImplementedError -from plugin.mixins import (APICallMixin, AppMixin, NavigationMixin, - SettingsMixin, UrlsMixin) +from plugin.mixins import ( + APICallMixin, + AppMixin, + NavigationMixin, + SettingsMixin, + UrlsMixin, +) from plugin.registry import registry from plugin.urls import PLUGIN_BASE @@ -24,9 +29,15 @@ class BaseMixinDefinition: def test_mixin_name(self): """Test that the mixin registers itseld correctly.""" # mixin name - self.assertIn(self.MIXIN_NAME, {item['key'] for item in self.mixin.registered_mixins.values()}) + self.assertIn( + self.MIXIN_NAME, + {item['key'] for item in self.mixin.registered_mixins.values()}, + ) # human name - self.assertIn(self.MIXIN_HUMAN_NAME, {item['human_name'] for item in self.mixin.registered_mixins.values()}) + self.assertIn( + self.MIXIN_HUMAN_NAME, + {item['human_name'] for item in self.mixin.registered_mixins.values()}, + ) class SettingsMixinTest(BaseMixinDefinition, InvenTreeTestCase): @@ -36,16 +47,19 @@ class SettingsMixinTest(BaseMixinDefinition, InvenTreeTestCase): MIXIN_NAME = 'settings' MIXIN_ENABLE_CHECK = 'has_settings' - TEST_SETTINGS = {'SETTING1': {'default': '123', }} + TEST_SETTINGS = {'SETTING1': {'default': '123'}} def setUp(self): """Setup for all tests.""" + class SettingsCls(SettingsMixin, InvenTreePlugin): SETTINGS = self.TEST_SETTINGS + self.mixin = SettingsCls() class NoSettingsCls(SettingsMixin, InvenTreePlugin): pass + self.mixin_nothing = NoSettingsCls() super().setUp() @@ -77,14 +91,18 @@ class UrlsMixinTest(BaseMixinDefinition, TestCase): def setUp(self): """Setup for all tests.""" + class UrlsCls(UrlsMixin, InvenTreePlugin): def test(): return 'ccc' - URLS = [re_path('testpath', test, name='test'), ] + + URLS = [re_path('testpath', test, name='test')] + self.mixin = UrlsCls() class NoUrlsCls(UrlsMixin, InvenTreePlugin): pass + self.mixin_nothing = NoUrlsCls() def test_function(self): @@ -96,8 +114,12 @@ class UrlsMixinTest(BaseMixinDefinition, TestCase): self.assertEqual(self.mixin.base_url, target_url) # urlpattern - target_pattern = re_path(f'^{plg_name}/', include((self.mixin.urls, plg_name)), name=plg_name) - self.assertEqual(self.mixin.urlpatterns.reverse_dict, target_pattern.reverse_dict) + target_pattern = re_path( + f'^{plg_name}/', include((self.mixin.urls, plg_name)), name=plg_name + ) + self.assertEqual( + self.mixin.urlpatterns.reverse_dict, target_pattern.reverse_dict + ) # resolve the view self.assertEqual(self.mixin.urlpatterns.resolve('/testpath').func(), 'ccc') @@ -120,8 +142,10 @@ class AppMixinTest(BaseMixinDefinition, TestCase): def setUp(self): """Setup for all tests.""" + class TestCls(AppMixin, InvenTreePlugin): pass + self.mixin = TestCls() def test_function(self): @@ -138,21 +162,24 @@ class NavigationMixinTest(BaseMixinDefinition, TestCase): def setUp(self): """Setup for all tests.""" + class NavigationCls(NavigationMixin, InvenTreePlugin): - NAVIGATION = [ - {'name': 'aa', 'link': 'plugin:test:test_view'}, - ] + NAVIGATION = [{'name': 'aa', 'link': 'plugin:test:test_view'}] NAVIGATION_TAB_NAME = 'abcd1' + self.mixin = NavigationCls() class NothingNavigationCls(NavigationMixin, InvenTreePlugin): pass + self.nothing_mixin = NothingNavigationCls() def test_function(self): """Test that a correct configuration functions.""" # check right configuration - self.assertEqual(self.mixin.navigation, [{'name': 'aa', 'link': 'plugin:test:test_view'}, ]) + self.assertEqual( + self.mixin.navigation, [{'name': 'aa', 'link': 'plugin:test:test_view'}] + ) # navigation name self.assertEqual(self.mixin.navigation_name, 'abcd1') @@ -161,8 +188,10 @@ class NavigationMixinTest(BaseMixinDefinition, TestCase): def test_fail(self): """Test that wrong links fail.""" with self.assertRaises(NotImplementedError): + class NavigationCls(NavigationMixin, InvenTreePlugin): NAVIGATION = ['aa', 'aa'] + NavigationCls() @@ -180,10 +209,7 @@ class APICallMixinTest(BaseMixinDefinition, TestCase): NAME = "Sample API Caller" SETTINGS = { - 'API_TOKEN': { - 'name': 'API Token', - 'protected': True, - }, + 'API_TOKEN': {'name': 'API Token', 'protected': True}, 'API_URL': { 'name': 'External URL', 'description': 'Where is your API located?', @@ -243,7 +269,7 @@ class APICallMixinTest(BaseMixinDefinition, TestCase): result = self.mixin.api_build_url_args({'a': 'b', 'c': 'd'}) self.assertEqual(result, '?a=b&c=d') # list args - result = self.mixin.api_build_url_args({'a': 'b', 'c': ['d', 'e', 'f', ]}) + result = self.mixin.api_build_url_args({'a': 'b', 'c': ['d', 'e', 'f']}) self.assertEqual(result, '?a=b&c=d,e,f') def test_api_call(self): @@ -277,7 +303,9 @@ class APICallMixinTest(BaseMixinDefinition, TestCase): self.assertEqual(result.reason, 'OK') # api_call with filter - result = self.mixin.api_call('repos/inventree/InvenTree/stargazers', url_args={'page': '2'}) + result = self.mixin.api_call( + 'repos/inventree/InvenTree/stargazers', url_args={'page': '2'} + ) self.assertTrue(result) def test_function_errors(self): @@ -293,8 +321,7 @@ class APICallMixinTest(BaseMixinDefinition, TestCase): # Too many data arguments with self.assertRaises(ValueError): self.mixin.api_call( - 'https://reqres.in/api/users/', - json={"a": 1, }, data={"a": 1}, + 'https://reqres.in/api/users/', json={"a": 1}, data={"a": 1} ) # Sending a request with a wrong data format should result in 40 @@ -303,7 +330,7 @@ class APICallMixinTest(BaseMixinDefinition, TestCase): data={"name": "morpheus", "job": "leader"}, method='POST', endpoint_is_url=True, - simple_response=False + simple_response=False, ) self.assertEqual(result.status_code, 400) @@ -313,12 +340,7 @@ class APICallMixinTest(BaseMixinDefinition, TestCase): class PanelMixinTests(InvenTreeTestCase): """Test that the PanelMixin plugin operates correctly.""" - fixtures = [ - 'category', - 'part', - 'location', - 'stock', - ] + fixtures = ['category', 'part', 'location', 'stock'] roles = 'all' @@ -352,9 +374,7 @@ class PanelMixinTests(InvenTreeTestCase): reverse('stock-item-detail', kwargs={'pk': 2}), reverse('stock-location-detail', kwargs={'pk': 1}), ]: - response = self.client.get( - url - ) + response = self.client.get(url) self.assertEqual(response.status_code, 200) @@ -440,6 +460,7 @@ class PanelMixinTests(InvenTreeTestCase): def test_mixin(self): """Test that ImplementationError is raised.""" with self.assertRaises(MixinNotImplementedError): + class Wrong(PanelMixin, InvenTreePlugin): pass diff --git a/InvenTree/plugin/base/label/label.py b/InvenTree/plugin/base/label/label.py index ea4d831cc4..aadd4be209 100644 --- a/InvenTree/plugin/base/label/label.py +++ b/InvenTree/plugin/base/label/label.py @@ -36,17 +36,16 @@ def print_label(plugin_slug: str, **kwargs): plugin.print_label(**kwargs) except Exception as e: # pragma: no cover # Plugin threw an error - notify the user who attempted to print - ctx = { - 'name': _('Label printing failed'), - 'message': str(e), - } + ctx = {'name': _('Label printing failed'), 'message': str(e)} user = kwargs.get('user', None) if user: # Log an error message to the database log_error('plugin.print_label') - logger.exception("Label printing failed: Sending notification to user '%s'", user) # pragma: no cover + logger.exception( + "Label printing failed: Sending notification to user '%s'", user + ) # pragma: no cover # Throw an error against the plugin instance common.notifications.trigger_notification( @@ -54,7 +53,7 @@ def print_label(plugin_slug: str, **kwargs): 'label.printing_failed', targets=[user], context=ctx, - delivery_methods={common.notifications.UIMessageNotification, }, + delivery_methods={common.notifications.UIMessageNotification}, ) if settings.TESTING: diff --git a/InvenTree/plugin/base/label/mixins.py b/InvenTree/plugin/base/label/mixins.py index 56194c29a4..4462526816 100644 --- a/InvenTree/plugin/base/label/mixins.py +++ b/InvenTree/plugin/base/label/mixins.py @@ -32,6 +32,7 @@ class LabelPrintingMixin: class MixinMeta: """Meta options for this mixin.""" + MIXIN_NAME = 'Label printing' def __init__(self): # pragma: no cover @@ -63,18 +64,24 @@ class LabelPrintingMixin: pdf_data = kwargs.get('pdf_data', None) if not pdf_data: - pdf_data = self.render_to_pdf(label, request, **kwargs).get_document().write_pdf() + pdf_data = ( + self.render_to_pdf(label, request, **kwargs).get_document().write_pdf() + ) - dpi = kwargs.get( - 'dpi', - InvenTreeSetting.get_setting('LABEL_DPI', 300) - ) + dpi = kwargs.get('dpi', InvenTreeSetting.get_setting('LABEL_DPI', 300)) # Convert to png data png = pdf2image.convert_from_bytes(pdf_data, dpi=dpi)[0] return png - def print_labels(self, label: LabelTemplate, items: list, request: Request, printing_options: dict, **kwargs): + def print_labels( + self, + label: LabelTemplate, + items: list, + request: Request, + printing_options: dict, + **kwargs, + ): """Print one or more labels with the provided template and items. Arguments: @@ -149,7 +156,9 @@ class LabelPrintingMixin: Note that the supplied kwargs may be different if the plugin overrides the print_labels() method. """ # Unimplemented (to be implemented by the particular plugin class) - raise MixinNotImplementedError('This Plugin must implement a `print_label` method') + raise MixinNotImplementedError( + 'This Plugin must implement a `print_label` method' + ) def offload_label(self, **kwargs): """Offload a single label (non-blocking) @@ -162,13 +171,11 @@ class LabelPrintingMixin: # Exclude the 'pdf_file' object - cannot be pickled kwargs.pop('pdf_file', None) - offload_task( - plugin_label.print_label, - self.plugin_slug(), - **kwargs - ) + offload_task(plugin_label.print_label, self.plugin_slug(), **kwargs) - def get_printing_options_serializer(self, request: Request, *args, **kwargs) -> Union[serializers.Serializer, None]: + def get_printing_options_serializer( + self, request: Request, *args, **kwargs + ) -> Union[serializers.Serializer, None]: """Return a serializer class instance with dynamic printing options. Arguments: diff --git a/InvenTree/plugin/base/label/test_label_mixin.py b/InvenTree/plugin/base/label/test_label_mixin.py index 2eedbcc156..17ae265fcd 100644 --- a/InvenTree/plugin/base/label/test_label_mixin.py +++ b/InvenTree/plugin/base/label/test_label_mixin.py @@ -23,12 +23,7 @@ from stock.models import StockItem, StockLocation class LabelMixinTests(InvenTreeAPITestCase): """Test that the Label mixin operates correctly.""" - fixtures = [ - 'category', - 'part', - 'location', - 'stock', - ] + fixtures = ['category', 'part', 'location', 'stock'] roles = 'all' @@ -38,7 +33,15 @@ class LabelMixinTests(InvenTreeAPITestCase): config.active = True config.save() - def do_url(self, parts, plugin_ref, label, url_name: str = 'api-part-label-print', url_single: str = 'part', invalid: bool = False): + def do_url( + self, + parts, + plugin_ref, + label, + url_name: str = 'api-part-label-print', + url_single: str = 'part', + invalid: bool = False, + ): """Generate an URL to print a label.""" # Construct URL kwargs = {} @@ -93,37 +96,19 @@ class LabelMixinTests(InvenTreeAPITestCase): response = self.client.post(url, {}) self.assertEqual(response.status_code, 405) - response = self.client.get( - url, - { - 'mixin': 'labels', - 'active': True, - } - ) + response = self.client.get(url, {'mixin': 'labels', 'active': True}) # No results matching this query! self.assertEqual(len(response.data), 0) # What about inactive? - response = self.client.get( - url, - { - 'mixin': 'labels', - 'active': False, - } - ) + response = self.client.get(url, {'mixin': 'labels', 'active': False}) self.assertEqual(len(response.data), 0) self.do_activate_plugin() # Should be available via the API now - response = self.client.get( - url, - { - 'mixin': 'labels', - 'active': True, - } - ) + response = self.client.get(url, {'mixin': 'labels', 'active': True}) self.assertEqual(len(response.data), 3) @@ -147,11 +132,15 @@ class LabelMixinTests(InvenTreeAPITestCase): # Non-exsisting plugin response = self.get(f'{url}123', expected_code=404) - self.assertIn(f'Plugin \'{plugin_ref}123\' not found', str(response.content, 'utf8')) + self.assertIn( + f'Plugin \'{plugin_ref}123\' not found', str(response.content, 'utf8') + ) # Inactive plugin response = self.get(url, expected_code=400) - self.assertIn(f'Plugin \'{plugin_ref}\' is not enabled', str(response.content, 'utf8')) + self.assertIn( + f'Plugin \'{plugin_ref}\' is not enabled', str(response.content, 'utf8') + ) # Active plugin self.do_activate_plugin() @@ -202,19 +191,31 @@ class LabelMixinTests(InvenTreeAPITestCase): self.do_activate_plugin() # test options response - options = self.options(self.do_url(parts, plugin_ref, label), expected_code=200).json() + options = self.options( + self.do_url(parts, plugin_ref, label), expected_code=200 + ).json() self.assertTrue("amount" in options["actions"]["POST"]) plg = registry.get_plugin(plugin_ref) with mock.patch.object(plg, "print_label") as print_label: # wrong value type - res = self.post(self.do_url(parts, plugin_ref, label), data={"amount": "-no-valid-int-"}, expected_code=400).json() + res = self.post( + self.do_url(parts, plugin_ref, label), + data={"amount": "-no-valid-int-"}, + expected_code=400, + ).json() self.assertTrue("amount" in res) print_label.assert_not_called() # correct value type - self.post(self.do_url(parts, plugin_ref, label), data={"amount": 13}, expected_code=200).json() - self.assertEqual(print_label.call_args.kwargs["printing_options"], {"amount": 13}) + self.post( + self.do_url(parts, plugin_ref, label), + data={"amount": 13}, + expected_code=200, + ).json() + self.assertEqual( + print_label.call_args.kwargs["printing_options"], {"amount": 13} + ) def test_printing_endpoints(self): """Cover the endpoints not covered by `test_printing_process`.""" @@ -237,22 +238,38 @@ class LabelMixinTests(InvenTreeAPITestCase): qs = qs.objects.all() # List endpoint - self.get(self.do_url(None, None, None, f'{url_name}-list', url_single), expected_code=200) + self.get( + self.do_url(None, None, None, f'{url_name}-list', url_single), + expected_code=200, + ) # List endpoint with filter - self.get(self.do_url(qs[:2], None, None, f'{url_name}-list', url_single, invalid=True), expected_code=200) + self.get( + self.do_url( + qs[:2], None, None, f'{url_name}-list', url_single, invalid=True + ), + expected_code=200, + ) # Single page printing - self.get(self.do_url(qs[:1], plugin_ref, label, f'{url_name}-print', url_single), expected_code=200) + self.get( + self.do_url(qs[:1], plugin_ref, label, f'{url_name}-print', url_single), + expected_code=200, + ) # Multi page printing - self.get(self.do_url(qs[:2], plugin_ref, label, f'{url_name}-print', url_single), expected_code=200) + self.get( + self.do_url(qs[:2], plugin_ref, label, f'{url_name}-print', url_single), + expected_code=200, + ) # Test StockItemLabels run_print_test(StockItemLabel, StockItem, 'api-stockitem-label', 'item') # Test StockLocationLabels - run_print_test(StockLocationLabel, StockLocation, 'api-stocklocation-label', 'location') + run_print_test( + StockLocationLabel, StockLocation, 'api-stocklocation-label', 'location' + ) # Test PartLabels run_print_test(PartLabel, Part, 'api-part-label', 'part') diff --git a/InvenTree/plugin/base/locate/api.py b/InvenTree/plugin/base/locate/api.py index a7d6f5d86e..560d35e295 100644 --- a/InvenTree/plugin/base/locate/api.py +++ b/InvenTree/plugin/base/locate/api.py @@ -13,9 +13,7 @@ from stock.models import StockItem, StockLocation class LocatePluginView(APIView): """Endpoint for using a custom plugin to identify or 'locate' a stock item or location.""" - permission_classes = [ - permissions.IsAuthenticated, - ] + permission_classes = [permissions.IsAuthenticated] def post(self, request, *args, **kwargs): """Check inputs and offload the task to the plugin.""" @@ -29,7 +27,9 @@ class LocatePluginView(APIView): plugins = registry.with_mixin('locate') if plugin not in [p.slug for p in plugins]: - raise ParseError(f"Plugin '{plugin}' is not installed, or does not support the location mixin") + raise ParseError( + f"Plugin '{plugin}' is not installed, or does not support the location mixin" + ) # StockItem to identify item_pk = request.data.get('item', None) @@ -37,17 +37,16 @@ class LocatePluginView(APIView): # StockLocation to identify location_pk = request.data.get('location', None) - data = { - "success": "Identification plugin activated", - "plugin": plugin, - } + data = {"success": "Identification plugin activated", "plugin": plugin} # StockItem takes priority if item_pk: try: StockItem.objects.get(pk=item_pk) - offload_task(registry.call_plugin_function, plugin, 'locate_stock_item', item_pk) + offload_task( + registry.call_plugin_function, plugin, 'locate_stock_item', item_pk + ) data['item'] = item_pk @@ -60,7 +59,12 @@ class LocatePluginView(APIView): try: StockLocation.objects.get(pk=location_pk) - offload_task(registry.call_plugin_function, plugin, 'locate_stock_location', location_pk) + offload_task( + registry.call_plugin_function, + plugin, + 'locate_stock_location', + location_pk, + ) data['location'] = location_pk diff --git a/InvenTree/plugin/base/locate/mixins.py b/InvenTree/plugin/base/locate/mixins.py index e6404af9c3..f04a8afd75 100644 --- a/InvenTree/plugin/base/locate/mixins.py +++ b/InvenTree/plugin/base/locate/mixins.py @@ -25,6 +25,7 @@ class LocateMixin: class MixinMeta: """Meta for mixin.""" + MIXIN_NAME = "Locate" def __init__(self): diff --git a/InvenTree/plugin/base/locate/test_locate.py b/InvenTree/plugin/base/locate/test_locate.py index 227f7553ec..e47d4a6cb5 100644 --- a/InvenTree/plugin/base/locate/test_locate.py +++ b/InvenTree/plugin/base/locate/test_locate.py @@ -11,12 +11,7 @@ from stock.models import StockItem, StockLocation class LocatePluginTests(InvenTreeAPITestCase): """Tests for LocateMixin.""" - fixtures = [ - 'category', - 'part', - 'location', - 'stock', - ] + fixtures = ['category', 'part', 'location', 'stock'] def test_installed(self): """Test that a locate plugin is actually installed.""" @@ -31,60 +26,41 @@ class LocatePluginTests(InvenTreeAPITestCase): url = reverse('api-locate-plugin') # Post without a plugin - response = self.post( - url, - {}, - expected_code=400 - ) + response = self.post(url, {}, expected_code=400) self.assertIn("'plugin' field must be supplied", str(response.data)) # Post with a plugin that does not exist, or is invalid for slug in ['xyz', 'event', 'plugin']: - response = self.post( - url, - { - 'plugin': slug, - }, - expected_code=400, + response = self.post(url, {'plugin': slug}, expected_code=400) + + self.assertIn( + f"Plugin '{slug}' is not installed, or does not support the location mixin", + str(response.data), ) - self.assertIn(f"Plugin '{slug}' is not installed, or does not support the location mixin", str(response.data)) - # Post with a valid plugin, but no other data - response = self.post( - url, - { - 'plugin': 'samplelocate', - }, - expected_code=400 - ) + response = self.post(url, {'plugin': 'samplelocate'}, expected_code=400) - self.assertIn("Must supply either 'item' or 'location' parameter", str(response.data)) + self.assertIn( + "Must supply either 'item' or 'location' parameter", str(response.data) + ) # Post with valid plugin, invalid item or location for pk in ['qq', 99999, -42]: response = self.post( - url, - { - 'plugin': 'samplelocate', - 'item': pk, - }, - expected_code=404 + url, {'plugin': 'samplelocate', 'item': pk}, expected_code=404 ) self.assertIn(f"StockItem matching PK '{pk}' not found", str(response.data)) response = self.post( - url, - { - 'plugin': 'samplelocate', - 'location': pk, - }, - expected_code=404, + url, {'plugin': 'samplelocate', 'location': pk}, expected_code=404 ) - self.assertIn(f"StockLocation matching PK '{pk}' not found", str(response.data)) + self.assertIn( + f"StockLocation matching PK '{pk}' not found", str(response.data) + ) def test_locate_item(self): """Test that the plugin correctly 'locates' a StockItem. @@ -100,12 +76,7 @@ class LocatePluginTests(InvenTreeAPITestCase): item.set_metadata('located', False) response = self.post( - url, - { - 'plugin': 'samplelocate', - 'item': 1, - }, - expected_code=200 + url, {'plugin': 'samplelocate', 'item': 1}, expected_code=200 ) self.assertEqual(response.data['item'], 1) @@ -120,16 +91,12 @@ class LocatePluginTests(InvenTreeAPITestCase): url = reverse('api-locate-plugin') for location in StockLocation.objects.all(): - location.set_metadata('located', False) response = self.post( url, - { - 'plugin': 'samplelocate', - 'location': location.pk, - }, - expected_code=200 + {'plugin': 'samplelocate', 'location': location.pk}, + expected_code=200, ) self.assertEqual(response.data['location'], location.pk) @@ -141,6 +108,7 @@ class LocatePluginTests(InvenTreeAPITestCase): def test_mixin_locate(self): """Test the sample mixin redirection.""" + class SamplePlugin(LocateMixin, InvenTreePlugin): pass diff --git a/InvenTree/plugin/broken/broken_sample.py b/InvenTree/plugin/broken/broken_sample.py index b873b93fa6..30f9b1a1ea 100644 --- a/InvenTree/plugin/broken/broken_sample.py +++ b/InvenTree/plugin/broken/broken_sample.py @@ -1,4 +1,5 @@ """Sample of a broken plugin.""" + from plugin import InvenTreePlugin diff --git a/InvenTree/plugin/builtin/barcodes/test_inventree_barcode.py b/InvenTree/plugin/builtin/barcodes/test_inventree_barcode.py index 2635471a3f..eead5d6f36 100644 --- a/InvenTree/plugin/builtin/barcodes/test_inventree_barcode.py +++ b/InvenTree/plugin/builtin/barcodes/test_inventree_barcode.py @@ -10,25 +10,16 @@ from InvenTree.unit_test import InvenTreeAPITestCase class TestInvenTreeBarcode(InvenTreeAPITestCase): """Tests for the integrated InvenTreeBarcode barcode plugin.""" - fixtures = [ - 'category', - 'part', - 'location', - 'stock', - 'company', - 'supplier_part', - ] + fixtures = ['category', 'part', 'location', 'stock', 'company', 'supplier_part'] def test_assign_errors(self): """Test error cases for assignment action.""" def test_assert_error(barcode_data): response = self.post( - reverse('api-barcode-link'), format='json', - data={ - 'barcode': barcode_data, - 'stockitem': 521 - }, + reverse('api-barcode-link'), + format='json', + data={'barcode': barcode_data, 'stockitem': 521}, expected_code=400, ) @@ -46,34 +37,25 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase): def assign(self, data, expected_code=None): """Perform a 'barcode assign' request""" return self.post( - reverse('api-barcode-link'), - data=data, - expected_code=expected_code + reverse('api-barcode-link'), data=data, expected_code=expected_code ) def unassign(self, data, expected_code=None): """Perform a 'barcode unassign' request""" return self.post( - reverse('api-barcode-unlink'), - data=data, - expected_code=expected_code, + reverse('api-barcode-unlink'), data=data, expected_code=expected_code ) def scan(self, data, expected_code=None): """Perform a 'scan' operation""" return self.post( - reverse('api-barcode-scan'), - data=data, - expected_code=expected_code + reverse('api-barcode-scan'), data=data, expected_code=expected_code ) def test_unassign_errors(self): """Test various error conditions for the barcode unassign endpoint""" # Fail without any fields provided - response = self.unassign( - {}, - expected_code=400, - ) + response = self.unassign({}, expected_code=400) self.assertIn('Missing data: Provide one of', str(response.data['error'])) @@ -89,45 +71,25 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase): self.assertIn('Multiple conflicting fields:', str(response.data['error'])) # Fail with an invalid StockItem instance - response = self.unassign( - { - 'stockitem': 'invalid', - }, - expected_code=400, - ) + response = self.unassign({'stockitem': 'invalid'}, expected_code=400) self.assertIn('Incorrect type', str(response.data['stockitem'])) # Fail with an invalid Part instance - response = self.unassign( - { - 'part': 99999999999, - }, - expected_code=400, - ) + response = self.unassign({'part': 99999999999}, expected_code=400) self.assertIn('object does not exist', str(response.data['part'])) def test_assign_to_stock_item(self): """Test that we can assign a unique barcode to a StockItem object""" # Test without providing any fields - response = self.assign( - { - 'barcode': 'abcde', - }, - expected_code=400 - ) + response = self.assign({'barcode': 'abcde'}, expected_code=400) self.assertIn('Missing data:', str(response.data)) # Permission error check response = self.assign( - { - 'barcode': 'abcdefg', - 'part': 1, - 'stockitem': 1, - }, - expected_code=403 + {'barcode': 'abcdefg', 'part': 1, 'stockitem': 1}, expected_code=403 ) self.assignRole('part.change') @@ -135,12 +97,7 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase): # Provide too many fields response = self.assign( - { - 'barcode': 'abcdefg', - 'part': 1, - 'stockitem': 1, - }, - expected_code=200 + {'barcode': 'abcdefg', 'part': 1, 'stockitem': 1}, expected_code=200 ) self.assertIn('Assigned barcode to part instance', str(response.data)) @@ -150,11 +107,7 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase): # Assign a barcode to a StockItem instance response = self.assign( - data={ - 'barcode': bc_data, - 'stockitem': 521, - }, - expected_code=200, + data={'barcode': bc_data, 'stockitem': 521}, expected_code=200 ) data = response.data @@ -169,22 +122,13 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase): # Now test that we cannot assign this barcode to something else response = self.assign( - data={ - 'barcode': bc_data, - 'stockitem': 1, - }, - expected_code=400 + data={'barcode': bc_data, 'stockitem': 1}, expected_code=400 ) self.assertIn('Barcode matches existing item', str(response.data)) # Next, test that we can 'unassign' the barcode via the API - response = self.unassign( - { - 'stockitem': 521, - }, - expected_code=200, - ) + response = self.unassign({'stockitem': 521}, expected_code=200) si.refresh_from_db() @@ -198,34 +142,19 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase): self.assignRole('part.change') # Test that an initial scan yields no results - response = self.scan( - { - 'barcode': barcode, - }, - expected_code=400 - ) + response = self.scan({'barcode': barcode}, expected_code=400) self.assignRole('part.change') # Attempt to assign to an invalid part ID response = self.assign( - { - 'barcode': barcode, - 'part': 99999999, - }, - expected_code=400, + {'barcode': barcode, 'part': 99999999}, expected_code=400 ) self.assertIn('object does not exist', str(response.data['part'])) # Test assigning to a valid part (should pass) - response = self.assign( - { - 'barcode': barcode, - 'part': 1, - }, - expected_code=200, - ) + response = self.assign({'barcode': barcode, 'part': 1}, expected_code=200) self.assertEqual(response.data['part']['pk'], 1) self.assertEqual(response.data['success'], 'Assigned barcode to part instance') @@ -236,37 +165,21 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase): self.assertEqual(p.barcode_hash, 'bc39d07e9a395c7b5658c231bf910fae') # Scanning the barcode should now reveal the 'Part' instance - response = self.scan( - { - 'barcode': barcode, - }, - expected_code=200, - ) + response = self.scan({'barcode': barcode}, expected_code=200) self.assertIn('success', response.data) self.assertEqual(response.data['plugin'], 'InvenTreeBarcode') self.assertEqual(response.data['part']['pk'], 1) # Attempting to assign the same barcode to a different part should result in an error - response = self.assign( - { - 'barcode': barcode, - 'part': 2, - }, - expected_code=400, - ) + response = self.assign({'barcode': barcode, 'part': 2}, expected_code=400) self.assertIn('Barcode matches existing item', str(response.data['error'])) self.assignRole('part.change') # Now test that we can unassign the barcode data also - response = self.unassign( - { - 'part': 1, - }, - expected_code=200, - ) + response = self.unassign({'part': 1}, expected_code=200) p.refresh_from_db() @@ -279,22 +192,14 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase): # Assign random barcode data to a StockLocation instance response = self.assign( - data={ - 'barcode': barcode, - 'stocklocation': 1, - }, - expected_code=403, + data={'barcode': barcode, 'stocklocation': 1}, expected_code=403 ) self.assignRole('stock_location.change') # Assign random barcode data to a StockLocation instance response = self.assign( - data={ - 'barcode': barcode, - 'stocklocation': 1, - }, - expected_code=200, + data={'barcode': barcode, 'stocklocation': 1}, expected_code=200 ) self.assertIn('success', response.data) @@ -308,22 +213,13 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase): # Check that an error is thrown if we try to assign the same value again response = self.assign( - data={ - 'barcode': barcode, - 'stocklocation': 2, - }, - expected_code=400 + data={'barcode': barcode, 'stocklocation': 2}, expected_code=400 ) self.assertIn('Barcode matches existing item', str(response.data['error'])) # Now, unassign the barcode - response = self.unassign( - { - 'stocklocation': 1, - }, - expected_code=200, - ) + response = self.unassign({'stocklocation': 1}, expected_code=200) loc.refresh_from_db() self.assertEqual(loc.barcode_data, '') @@ -347,12 +243,7 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase): for barcode in ['abcde', 'ABCDE', '12345']: si.assign_barcode(barcode_data=barcode) - response = self.scan( - { - 'barcode': barcode, - }, - expected_code=200, - ) + response = self.scan({'barcode': barcode}, expected_code=200) self.assertIn('success', response.data) self.assertEqual(response.data['stockitem']['pk'], 1) @@ -360,58 +251,37 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase): def test_scan_inventree(self): """Test scanning of first-party barcodes""" # Scan a StockItem object (which does not exist) - response = self.scan( - { - 'barcode': '{"stockitem": 5}', - }, - expected_code=400, - ) + response = self.scan({'barcode': '{"stockitem": 5}'}, expected_code=400) self.assertIn('No match found for barcode data', str(response.data)) # Scan a StockItem object (which does exist) - response = self.scan( - { - 'barcode': '{"stockitem": 1}', - }, - expected_code=200 - ) + response = self.scan({'barcode': '{"stockitem": 1}'}, expected_code=200) self.assertIn('success', response.data) self.assertIn('stockitem', response.data) self.assertEqual(response.data['stockitem']['pk'], 1) # Scan a StockLocation object - response = self.scan( - { - 'barcode': '{"stocklocation": 5}', - }, - expected_code=200, - ) + response = self.scan({'barcode': '{"stocklocation": 5}'}, expected_code=200) self.assertIn('success', response.data) self.assertEqual(response.data['stocklocation']['pk'], 5) - self.assertEqual(response.data['stocklocation']['api_url'], '/api/stock/location/5/') - self.assertEqual(response.data['stocklocation']['web_url'], '/stock/location/5/') + self.assertEqual( + response.data['stocklocation']['api_url'], '/api/stock/location/5/' + ) + self.assertEqual( + response.data['stocklocation']['web_url'], '/stock/location/5/' + ) self.assertEqual(response.data['plugin'], 'InvenTreeBarcode') # Scan a Part object - response = self.scan( - { - 'barcode': '{"part": 5}' - }, - expected_code=200, - ) + response = self.scan({'barcode': '{"part": 5}'}, expected_code=200) self.assertEqual(response.data['part']['pk'], 5) # Scan a SupplierPart instance - response = self.scan( - { - 'barcode': '{"supplierpart": 1}', - }, - expected_code=200 - ) + response = self.scan({'barcode': '{"supplierpart": 1}'}, expected_code=200) self.assertEqual(response.data['supplierpart']['pk'], 1) self.assertEqual(response.data['plugin'], 'InvenTreeBarcode') diff --git a/InvenTree/plugin/builtin/integration/core_notifications.py b/InvenTree/plugin/builtin/integration/core_notifications.py index a988cf8287..b2e76f9bcd 100644 --- a/InvenTree/plugin/builtin/integration/core_notifications.py +++ b/InvenTree/plugin/builtin/integration/core_notifications.py @@ -11,8 +11,7 @@ import InvenTree.email import InvenTree.helpers import InvenTree.tasks from plugin import InvenTreePlugin, registry -from plugin.mixins import (BulkNotificationMethod, SettingsContentMixin, - SettingsMixin) +from plugin.mixins import BulkNotificationMethod, SettingsContentMixin, SettingsMixin class PlgMixin: @@ -26,7 +25,9 @@ class PlgMixin: return InvenTreeCoreNotificationsPlugin -class InvenTreeCoreNotificationsPlugin(SettingsContentMixin, SettingsMixin, InvenTreePlugin): +class InvenTreeCoreNotificationsPlugin( + SettingsContentMixin, SettingsMixin, InvenTreePlugin +): """Core notification methods for InvenTree.""" NAME = "InvenTreeCoreNotificationsPlugin" @@ -44,7 +45,9 @@ class InvenTreeCoreNotificationsPlugin(SettingsContentMixin, SettingsMixin, Inve }, 'ENABLE_NOTIFICATION_SLACK': { 'name': _('Enable slack notifications'), - 'description': _('Allow sending of slack channel messages for event notifications'), + 'description': _( + 'Allow sending of slack channel messages for event notifications' + ), 'default': False, 'validator': bool, }, @@ -71,11 +74,7 @@ class InvenTreeCoreNotificationsPlugin(SettingsContentMixin, SettingsMixin, Inve METHOD_NAME = 'mail' METHOD_ICON = 'fa-envelope' - CONTEXT_EXTRA = [ - ('template', ), - ('template', 'html', ), - ('template', 'subject', ), - ] + CONTEXT_EXTRA = [('template',), ('template', 'html'), ('template', 'subject')] GLOBAL_SETTING = 'ENABLE_NOTIFICATION_EMAILS' USER_SETTING = { 'name': _('Enable email notifications'), @@ -89,7 +88,6 @@ class InvenTreeCoreNotificationsPlugin(SettingsContentMixin, SettingsMixin, Inve allowed_users = [] for user in self.targets: - if not user.is_active: # Ignore any users who have been deactivated continue @@ -99,17 +97,19 @@ class InvenTreeCoreNotificationsPlugin(SettingsContentMixin, SettingsMixin, Inve if allows_emails: allowed_users.append(user) - return EmailAddress.objects.filter( - user__in=allowed_users, - ) + return EmailAddress.objects.filter(user__in=allowed_users) def send_bulk(self): """Send the notifications out via email.""" - html_message = render_to_string(self.context['template']['html'], self.context) + html_message = render_to_string( + self.context['template']['html'], self.context + ) targets = self.targets.values_list('email', flat=True) # Prefix the 'instance title' to the email subject - instance_title = common.models.InvenTreeSetting.get_setting('INVENTREE_INSTANCE') + instance_title = common.models.InvenTreeSetting.get_setting( + 'INVENTREE_INSTANCE' + ) subject = self.context['template'].get('subject', '') @@ -139,32 +139,37 @@ class InvenTreeCoreNotificationsPlugin(SettingsContentMixin, SettingsMixin, Inve if not url: return False - ret = requests.post(url, json={ - 'text': str(self.context['message']), - 'blocks': [ - { - "type": "section", - "text": { - "type": "plain_text", - "text": str(self.context['name']) - } - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": str(self.context['message']) - }, - "accessory": { - "type": "button", + ret = requests.post( + url, + json={ + 'text': str(self.context['message']), + 'blocks': [ + { + "type": "section", "text": { "type": "plain_text", - "text": str(_("Open link")), "emoji": True + "text": str(self.context['name']), }, - "value": f'{self.category}_{self.obj.pk}', - "url": self.context['link'], - "action_id": "button-action" - } - }] - }) + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": str(self.context['message']), + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": str(_("Open link")), + "emoji": True, + }, + "value": f'{self.category}_{self.obj.pk}', + "url": self.context['link'], + "action_id": "button-action", + }, + }, + ], + }, + ) return ret.ok diff --git a/InvenTree/plugin/builtin/integration/currency_exchange.py b/InvenTree/plugin/builtin/integration/currency_exchange.py index 33788c0ba2..383bc87566 100644 --- a/InvenTree/plugin/builtin/integration/currency_exchange.py +++ b/InvenTree/plugin/builtin/integration/currency_exchange.py @@ -1,6 +1,5 @@ """Builtin plugin for requesting exchange rates from an external API.""" - import logging from django.utils.translation import gettext_lazy as _ @@ -28,20 +27,20 @@ class InvenTreeCurrencyExchange(APICallMixin, CurrencyExchangeMixin, InvenTreePl """Request exchange rate data from external API""" response = self.api_call( 'latest', - url_args={ - 'from': [base_currency], - 'to': symbols, - }, - simple_response=False + url_args={'from': [base_currency], 'to': symbols}, + simple_response=False, ) if response.status_code == 200: - rates = response.json().get('rates', {}) rates[base_currency] = 1.00 return rates - logger.warning("Failed to update exchange rates from %s: Server returned status %s", self.api_url, response.status_code) + logger.warning( + "Failed to update exchange rates from %s: Server returned status %s", + self.api_url, + response.status_code, + ) return None @property diff --git a/InvenTree/plugin/builtin/integration/test_core_notifications.py b/InvenTree/plugin/builtin/integration/test_core_notifications.py index 60ed1d6d35..2c6e9f9455 100644 --- a/InvenTree/plugin/builtin/integration/test_core_notifications.py +++ b/InvenTree/plugin/builtin/integration/test_core_notifications.py @@ -4,8 +4,9 @@ from django.core import mail from part.test_part import BaseNotificationIntegrationTest from plugin import registry -from plugin.builtin.integration.core_notifications import \ - InvenTreeCoreNotificationsPlugin +from plugin.builtin.integration.core_notifications import ( + InvenTreeCoreNotificationsPlugin, +) from plugin.models import NotificationUserSetting @@ -25,7 +26,7 @@ class CoreNotificationTestTests(BaseNotificationIntegrationTest): value=True, change_user=self.user, user=self.user, - method=InvenTreeCoreNotificationsPlugin.EmailNotification.METHOD_NAME + method=InvenTreeCoreNotificationsPlugin.EmailNotification.METHOD_NAME, ) # run through diff --git a/InvenTree/plugin/builtin/labels/inventree_label.py b/InvenTree/plugin/builtin/labels/inventree_label.py index 8fa02c9042..5ad9178b81 100644 --- a/InvenTree/plugin/builtin/labels/inventree_label.py +++ b/InvenTree/plugin/builtin/labels/inventree_label.py @@ -30,7 +30,7 @@ class InvenTreeLabelPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlugin): 'description': _('Enable debug mode - returns raw HTML instead of PDF'), 'validator': bool, 'default': False, - }, + } } def print_labels(self, label: LabelTemplate, items: list, request, **kwargs): @@ -45,7 +45,6 @@ class InvenTreeLabelPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlugin): output_file = None for item in items: - label.object_to_print = item outputs.append(self.print_label(label, request, debug=debug, **kwargs)) @@ -70,15 +69,12 @@ class InvenTreeLabelPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlugin): output_file = ContentFile(pdf, 'labels.pdf') # Save the generated file to the database - output = LabelOutput.objects.create( - label=output_file, - user=request.user - ) + output = LabelOutput.objects.create(label=output_file, user=request.user) return JsonResponse({ 'file': output.label.url, 'success': True, - 'message': f'{len(items)} labels generated' + 'message': f'{len(items)} labels generated', }) def print_label(self, label: LabelTemplate, request, **kwargs): diff --git a/InvenTree/plugin/builtin/labels/label_sheet.py b/InvenTree/plugin/builtin/labels/label_sheet.py index 4a48edbe25..c81065e9b0 100644 --- a/InvenTree/plugin/builtin/labels/label_sheet.py +++ b/InvenTree/plugin/builtin/labels/label_sheet.py @@ -26,26 +26,26 @@ class LabelPrintingOptionsSerializer(serializers.Serializer): choices=report.helpers.report_page_size_options(), default='A4', label=_('Page Size'), - help_text=_('Page size for the label sheet') + help_text=_('Page size for the label sheet'), ) skip = serializers.IntegerField( default=0, label=_('Skip Labels'), help_text=_('Skip this number of labels when printing label sheets'), - min_value=0 + min_value=0, ) border = serializers.BooleanField( default=False, label=_('Border'), - help_text=_('Print a border around each label') + help_text=_('Print a border around each label'), ) landscape = serializers.BooleanField( default=False, label=_('Landscape'), - help_text=_('Print the label sheet in landscape mode') + help_text=_('Print the label sheet in landscape mode'), ) @@ -118,8 +118,9 @@ class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlug idx = 0 while idx < n_labels: - - if page := self.print_page(label, items[idx:idx + n_cells], request, **document_data): + if page := self.print_page( + label, items[idx : idx + n_cells], request, **document_data + ): pages.append(page) idx += n_cells @@ -136,15 +137,12 @@ class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlug output_file = ContentFile(document, 'labels.pdf') - output = LabelOutput.objects.create( - label=output_file, - user=request.user - ) + output = LabelOutput.objects.create(label=output_file, user=request.user) return JsonResponse({ 'file': output.label.url, 'success': True, - 'message': f'{len(items)} labels generated' + 'message': f'{len(items)} labels generated', }) def print_page(self, label: LabelTemplate, items: list, request, **kwargs): @@ -173,7 +171,6 @@ class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlug html += "" for col in range(n_cols): - # Cell index idx = row * n_cols + col @@ -190,9 +187,7 @@ class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlug # Render the individual label template # Note that we disable @page styling for this cell = label.render_as_string( - request, - target_object=items[idx], - insert_page_style=False + request, target_object=items[idx], insert_page_style=False ) html += cell except Exception as exc: @@ -229,18 +224,22 @@ class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlug cell_styles = [] for row in range(n_rows): - cell_styles.append(f""" + cell_styles.append( + f""" .label-sheet-row-{row} {{ top: {row * label_height}mm; }} - """) + """ + ) for col in range(n_cols): - cell_styles.append(f""" + cell_styles.append( + f""" .label-sheet-col-{col} {{ left: {col * label_width}mm; }} - """) + """ + ) cell_styles = "\n".join(cell_styles) diff --git a/InvenTree/plugin/builtin/suppliers/lcsc.py b/InvenTree/plugin/builtin/suppliers/lcsc.py index 6674b39924..f47e7ac54b 100644 --- a/InvenTree/plugin/builtin/suppliers/lcsc.py +++ b/InvenTree/plugin/builtin/suppliers/lcsc.py @@ -50,10 +50,7 @@ class LCSCPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin): # Extract fields fields = SupplierBarcodeMixin.split_fields( - barcode_data, - delimiter=',', - header='{', - trailer='}', + barcode_data, delimiter=',', header='{', trailer='}' ) fields = dict(pair.split(":") for pair in fields) diff --git a/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py b/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py index 73f19d3fec..f6491eec6b 100644 --- a/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py +++ b/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py @@ -22,12 +22,15 @@ class SupplierBarcodeTests(InvenTreeAPITestCase): part = Part.objects.create(name="Test Part", description="Test Part") manufacturer = Company.objects.create( - name="Test Manufacturer", is_manufacturer=True) + name="Test Manufacturer", is_manufacturer=True + ) mpart1 = ManufacturerPart.objects.create( - part=part, manufacturer=manufacturer, MPN="MC34063ADR") + part=part, manufacturer=manufacturer, MPN="MC34063ADR" + ) mpart2 = ManufacturerPart.objects.create( - part=part, manufacturer=manufacturer, MPN="LDK320ADU33R") + part=part, manufacturer=manufacturer, MPN="LDK320ADU33R" + ) supplier = Company.objects.create(name="Supplier", is_supplier=True) mouser = Company.objects.create(name="Mouser Test", is_supplier=True) @@ -45,7 +48,9 @@ class SupplierBarcodeTests(InvenTreeAPITestCase): def test_digikey_barcode(self): """Test digikey barcode""" - result = self.post(self.SCAN_URL, data={"barcode": DIGIKEY_BARCODE}, expected_code=200) + result = self.post( + self.SCAN_URL, data={"barcode": DIGIKEY_BARCODE}, expected_code=200 + ) self.assertEqual(result.data['plugin'], 'DigiKeyPlugin') supplier_part_data = result.data.get("supplierpart") @@ -56,7 +61,9 @@ class SupplierBarcodeTests(InvenTreeAPITestCase): def test_digikey_2_barcode(self): """Test digikey barcode which uses 30P instead of P""" - result = self.post(self.SCAN_URL, data={"barcode": DIGIKEY_BARCODE_2}, expected_code=200) + result = self.post( + self.SCAN_URL, data={"barcode": DIGIKEY_BARCODE_2}, expected_code=200 + ) self.assertEqual(result.data['plugin'], 'DigiKeyPlugin') supplier_part_data = result.data.get("supplierpart") @@ -72,7 +79,9 @@ class SupplierBarcodeTests(InvenTreeAPITestCase): def test_mouser_barcode(self): """Test mouser barcode with custom order number.""" - result = self.post(self.SCAN_URL, data={"barcode": MOUSER_BARCODE}, expected_code=200) + result = self.post( + self.SCAN_URL, data={"barcode": MOUSER_BARCODE}, expected_code=200 + ) supplier_part_data = result.data.get("supplierpart") self.assertIn('pk', supplier_part_data) @@ -83,7 +92,9 @@ class SupplierBarcodeTests(InvenTreeAPITestCase): def test_old_mouser_barcode(self): """Test old mouser barcode with messed up header.""" - result = self.post(self.SCAN_URL, data={"barcode": MOUSER_BARCODE_OLD}, expected_code=200) + result = self.post( + self.SCAN_URL, data={"barcode": MOUSER_BARCODE_OLD}, expected_code=200 + ) supplier_part_data = result.data.get("supplierpart") self.assertIn('pk', supplier_part_data) @@ -93,7 +104,9 @@ class SupplierBarcodeTests(InvenTreeAPITestCase): def test_lcsc_barcode(self): """Test LCSC barcode.""" - result = self.post(self.SCAN_URL, data={"barcode": LCSC_BARCODE}, expected_code=200) + result = self.post( + self.SCAN_URL, data={"barcode": LCSC_BARCODE}, expected_code=200 + ) self.assertEqual(result.data['plugin'], 'LCSCPlugin') @@ -106,7 +119,9 @@ class SupplierBarcodeTests(InvenTreeAPITestCase): def test_tme_qrcode(self): """Test TME QR-Code.""" - result = self.post(self.SCAN_URL, data={"barcode": TME_QRCODE}, expected_code=200) + result = self.post( + self.SCAN_URL, data={"barcode": TME_QRCODE}, expected_code=200 + ) self.assertEqual(result.data['plugin'], 'TMEPlugin') @@ -118,7 +133,9 @@ class SupplierBarcodeTests(InvenTreeAPITestCase): def test_tme_barcode2d(self): """Test TME DataMatrix-Code.""" - result = self.post(self.SCAN_URL, data={"barcode": TME_DATAMATRIX_CODE}, expected_code=200) + result = self.post( + self.SCAN_URL, data={"barcode": TME_DATAMATRIX_CODE}, expected_code=200 + ) self.assertEqual(result.data['plugin'], 'TMEPlugin') @@ -152,8 +169,7 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase): ) supplier_parts1 = [ - SupplierPart(SKU=f"1_{i}", part=part, supplier=supplier) - for i in range(6) + SupplierPart(SKU=f"1_{i}", part=part, supplier=supplier) for i in range(6) ] supplier_parts1.insert( @@ -170,13 +186,13 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase): self.purchase_order2.place_order() supplier_parts2 = [ - SupplierPart(SKU=f"2_{i}", part=part, supplier=mouser) - for i in range(6) + SupplierPart(SKU=f"2_{i}", part=part, supplier=mouser) for i in range(6) ] - supplier_parts2.insert(3, SupplierPart( - SKU="42", part=part, manufacturer_part=mpart, supplier=mouser - )) + supplier_parts2.insert( + 3, + SupplierPart(SKU="42", part=part, manufacturer_part=mpart, supplier=mouser), + ) for supplier_part in supplier_parts2: supplier_part.save() @@ -199,11 +215,18 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase): result3 = self.post(url, data={"barcode": DIGIKEY_BARCODE}, expected_code=400) self.assertEqual(result3.data['error'], "Item has already been received") - result4 = self.post(url, data={"barcode": DIGIKEY_BARCODE[:-1]}, expected_code=400) + result4 = self.post( + url, data={"barcode": DIGIKEY_BARCODE[:-1]}, expected_code=400 + ) assert result4.data["error"].startswith( - "Failed to find pending line item for supplier part") + "Failed to find pending line item for supplier part" + ) - result5 = self.post(reverse("api-barcode-scan"), data={"barcode": DIGIKEY_BARCODE}, expected_code=200) + result5 = self.post( + reverse("api-barcode-scan"), + data={"barcode": DIGIKEY_BARCODE}, + expected_code=200, + ) stock_item = StockItem.objects.get(pk=result5.data["stockitem"]["pk"]) assert stock_item.supplier_part.SKU == "296-LM358BIDDFRCT-ND" assert stock_item.quantity == 10 @@ -216,7 +239,9 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase): result1 = self.post(url, data={"barcode": MOUSER_BARCODE}) assert "success" in result1.data - result2 = self.post(reverse("api-barcode-scan"), data={"barcode": MOUSER_BARCODE}) + result2 = self.post( + reverse("api-barcode-scan"), data={"barcode": MOUSER_BARCODE} + ) stock_item = StockItem.objects.get(pk=result2.data["stockitem"]["pk"]) assert stock_item.supplier_part.SKU == "42" assert stock_item.supplier_part.manufacturer_part.MPN == "MC34063ADR" @@ -232,7 +257,9 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase): result1 = self.post(url, data={"barcode": MOUSER_BARCODE}) assert "success" in result1.data - result2 = self.post(reverse("api-barcode-scan"), data={"barcode": MOUSER_BARCODE}) + result2 = self.post( + reverse("api-barcode-scan"), data={"barcode": MOUSER_BARCODE} + ) stock_item = StockItem.objects.get(pk=result2.data["stockitem"]["pk"]) assert stock_item.location == stock_location @@ -250,7 +277,9 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase): result1 = self.post(url, data={"barcode": MOUSER_BARCODE}) assert "success" in result1.data - result2 = self.post(reverse("api-barcode-scan"), data={"barcode": MOUSER_BARCODE}) + result2 = self.post( + reverse("api-barcode-scan"), data={"barcode": MOUSER_BARCODE} + ) stock_item = StockItem.objects.get(pk=result2.data["stockitem"]["pk"]) assert stock_item.location == stock_location2 @@ -268,7 +297,9 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase): result1 = self.post(url, data={"barcode": MOUSER_BARCODE}) assert "success" in result1.data - result2 = self.post(reverse("api-barcode-scan"), data={"barcode": MOUSER_BARCODE}) + result2 = self.post( + reverse("api-barcode-scan"), data={"barcode": MOUSER_BARCODE} + ) stock_item = StockItem.objects.get(pk=result2.data["stockitem"]["pk"]) assert stock_item.location == stock_location2 @@ -280,11 +311,14 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase): url = reverse("api-barcode-po-receive") barcode = MOUSER_BARCODE.replace("\x1dKP0-1337", "") - result1 = self.post(url, data={ - "barcode": barcode, - "purchase_order": self.purchase_order2.pk, - "location": stock_location2.pk, - }) + result1 = self.post( + url, + data={ + "barcode": barcode, + "purchase_order": self.purchase_order2.pk, + "location": stock_location2.pk, + }, + ) assert "success" in result1.data result2 = self.post(reverse("api-barcode-scan"), data={"barcode": barcode}) @@ -326,7 +360,6 @@ DIGIKEY_BARCODE_3 = ( "\x1d20Z0000000000000000000000000000000000000000000000000000000000000000000" "00000000000000000000000000000000000000000000000000000000000000000000000000" "0000000000000000000000000000000000" - ) MOUSER_BARCODE = ( diff --git a/InvenTree/plugin/builtin/suppliers/tme.py b/InvenTree/plugin/builtin/suppliers/tme.py index a529553ed1..1be052274d 100644 --- a/InvenTree/plugin/builtin/suppliers/tme.py +++ b/InvenTree/plugin/builtin/suppliers/tme.py @@ -60,7 +60,7 @@ class TMEPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin): for item in barcode_data.split(" "): for k, v in self.ecia_field_map().items(): if item.startswith(k): - barcode_fields[v] = item[len(k):] + barcode_fields[v] = item[len(k) :] else: return {} diff --git a/InvenTree/plugin/events.py b/InvenTree/plugin/events.py index 800233bd9a..4ac5ff0544 100644 --- a/InvenTree/plugin/events.py +++ b/InvenTree/plugin/events.py @@ -1,10 +1,5 @@ """Import helper for events.""" -from plugin.base.event.events import (process_event, register_event, - trigger_event) +from plugin.base.event.events import process_event, register_event, trigger_event -__all__ = [ - 'process_event', - 'register_event', - 'trigger_event', -] +__all__ = ['process_event', 'register_event', 'trigger_event'] diff --git a/InvenTree/plugin/helpers.py b/InvenTree/plugin/helpers.py index d896ce10c2..0dc3239512 100644 --- a/InvenTree/plugin/helpers.py +++ b/InvenTree/plugin/helpers.py @@ -41,11 +41,13 @@ class MixinImplementationError(ValueError): Mostly raised if constant is missing """ + pass class MixinNotImplementedError(NotImplementedError): """Error if necessary mixin function was not overwritten.""" + pass @@ -73,7 +75,9 @@ def handle_error(error, do_raise: bool = True, do_log: bool = True, log_name: st try: path_obj = pathlib.Path(package_path).relative_to(settings.BASE_DIR) path_parts = [*path_obj.parts] - path_parts[-1] = path_parts[-1].replace(path_obj.suffix, '') # remove suffix + path_parts[-1] = path_parts[-1].replace( + path_obj.suffix, '' + ) # remove suffix # remove path prefixes if path_parts[0] == 'plugin': @@ -94,7 +98,11 @@ def handle_error(error, do_raise: bool = True, do_log: bool = True, log_name: st if do_raise: # do a straight raise if we are playing with environment variables at execution time, ignore the broken sample - if settings.TESTING_ENV and package_name != 'integration.broken_sample' and isinstance(error, IntegrityError): + if ( + settings.TESTING_ENV + and package_name != 'integration.broken_sample' + and isinstance(error, IntegrityError) + ): raise error # pragma: no cover raise IntegrationPluginError(package_name, str(error)) @@ -103,6 +111,8 @@ def handle_error(error, do_raise: bool = True, do_log: bool = True, log_name: st def get_entrypoints(): """Returns list for entrypoints for InvenTree plugins.""" return entry_points().get('inventree_plugins', []) + + # endregion @@ -123,7 +133,6 @@ def get_git_log(path): # only do this if we are not in test mode if not isInTestMode(): # pragma: no cover - try: repo = Repo(path) head = repo.head() @@ -133,7 +142,7 @@ def get_git_log(path): head.decode(), commit.author.decode().split('<')[0][:-1], commit.author.decode().split('<')[1][:-1], - datetime.datetime.fromtimestamp(commit.author_time, ).isoformat(), + datetime.datetime.fromtimestamp(commit.author_time).isoformat(), commit.message.decode().split('\n')[0], ] except NotGitRepository: @@ -142,7 +151,15 @@ def get_git_log(path): if not output: output = 5 * [''] # pragma: no cover - return {'hash': output[0], 'author': output[1], 'mail': output[2], 'date': output[3], 'message': output[4]} + return { + 'hash': output[0], + 'author': output[1], + 'mail': output[2], + 'date': output[3], + 'message': output[4], + } + + # endregion @@ -199,6 +216,8 @@ def get_plugins(pkg, baseclass, path=None): plugins.append(plugin) return plugins + + # endregion @@ -208,7 +227,9 @@ def render_template(plugin, template_file, context=None): try: tmp = template.loader.get_template(template_file) except template.TemplateDoesNotExist: - logger.exception("Plugin %s could not locate template '%s'", plugin.slug, template_file) + logger.exception( + "Plugin %s could not locate template '%s'", plugin.slug, template_file + ) return f"""
    @@ -228,4 +249,5 @@ def render_text(text, context=None): return template.Template(text).render(ctx) + # endregion diff --git a/InvenTree/plugin/installer.py b/InvenTree/plugin/installer.py index 885114ab83..113bcf014c 100644 --- a/InvenTree/plugin/installer.py +++ b/InvenTree/plugin/installer.py @@ -28,9 +28,7 @@ def pip_command(*args): logger.info("Running pip command: %s", ' '.join(command)) return subprocess.check_output( - command, - cwd=settings.BASE_DIR.parent, - stderr=subprocess.STDOUT, + command, cwd=settings.BASE_DIR.parent, stderr=subprocess.STDOUT ) @@ -137,7 +135,9 @@ def install_plugin(url=None, packagename=None, user=None): - We must detect that we are running within a virtual environment """ if user and not user.is_staff: - raise ValidationError(_("Permission denied: only staff users can install plugins")) + raise ValidationError( + _("Permission denied: only staff users can install plugins") + ) logger.debug("install_plugin: %s, %s", url, packagename) @@ -155,7 +155,9 @@ def install_plugin(url=None, packagename=None, user=None): if url: # use custom registration / VCS - if True in [identifier in url for identifier in ['git+https', 'hg+https', 'svn+svn', ]]: + if True in [ + identifier in url for identifier in ['git+https', 'hg+https', 'svn+svn'] + ]: # using a VCS provider if packagename: full_pkg = f'{packagename}@{url}' @@ -197,9 +199,7 @@ def install_plugin(url=None, packagename=None, user=None): output = error.output.decode('utf-8') logger.exception("Plugin installation failed: %s", str(output)) - errors = [ - _("Plugin installation failed"), - ] + errors = [_("Plugin installation failed")] for msg in output.split("\n"): msg = msg.strip() @@ -217,6 +217,7 @@ def install_plugin(url=None, packagename=None, user=None): # Reload the plugin registry, to discover the new plugin from plugin.registry import registry + registry.reload_plugins(full_reload=True, force_reload=True, collect=True) return ret diff --git a/InvenTree/plugin/mixins/__init__.py b/InvenTree/plugin/mixins/__init__.py index 07f6eed485..0e39462ad6 100644 --- a/InvenTree/plugin/mixins/__init__.py +++ b/InvenTree/plugin/mixins/__init__.py @@ -1,15 +1,17 @@ """Utility class to enable simpler imports.""" -from common.notifications import (BulkNotificationMethod, - SingleNotificationMethod) +from common.notifications import BulkNotificationMethod, SingleNotificationMethod from plugin.base.action.mixins import ActionMixin from plugin.base.barcodes.mixins import BarcodeMixin, SupplierBarcodeMixin from plugin.base.event.mixins import EventMixin from plugin.base.integration.APICallMixin import APICallMixin from plugin.base.integration.AppMixin import AppMixin from plugin.base.integration.CurrencyExchangeMixin import CurrencyExchangeMixin -from plugin.base.integration.mixins import (NavigationMixin, PanelMixin, - SettingsContentMixin) +from plugin.base.integration.mixins import ( + NavigationMixin, + PanelMixin, + SettingsContentMixin, +) from plugin.base.integration.ReportMixin import ReportMixin from plugin.base.integration.ScheduleMixin import ScheduleMixin from plugin.base.integration.SettingsMixin import SettingsMixin diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py index fef716b86a..fa3183e395 100644 --- a/InvenTree/plugin/models.py +++ b/InvenTree/plugin/models.py @@ -25,14 +25,12 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model): class Meta: """Meta for PluginConfig.""" + verbose_name = _("Plugin Configuration") verbose_name_plural = _("Plugin Configurations") key = models.CharField( - unique=True, - max_length=255, - verbose_name=_('Key'), - help_text=_('Key of plugin'), + unique=True, max_length=255, verbose_name=_('Key'), help_text=_('Key of plugin') ) name = models.CharField( @@ -44,9 +42,7 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model): ) active = models.BooleanField( - default=False, - verbose_name=_('Active'), - help_text=_('Is the plugin active'), + default=False, verbose_name=_('Active'), help_text=_('Is the plugin active') ) def __str__(self) -> str: @@ -61,7 +57,9 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model): """Returns all registered mixins.""" try: if inspect.isclass(self.plugin): - return self.plugin.get_registered_mixins(self, with_base=True, with_cls=False) + return self.plugin.get_registered_mixins( + self, with_base=True, with_cls=False + ) return self.plugin.get_registered_mixins(with_base=True, with_cls=False) except (AttributeError, ValueError): # pragma: no cover return {} @@ -94,9 +92,19 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model): return result self.meta = { - key: get_plugin_meta(key) for key in ['slug', 'human_name', 'description', 'author', - 'pub_date', 'version', 'website', 'license', - 'package_path', 'settings_url', ] + key: get_plugin_meta(key) + for key in [ + 'slug', + 'human_name', + 'description', + 'author', + 'pub_date', + 'version', + 'website', + 'license', + 'package_path', + 'settings_url', + ] } # Save plugin @@ -105,7 +113,9 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model): def __getstate__(self): """Customize pickling behavior.""" state = super().__getstate__() - state.pop("plugin", None) # plugin cannot be pickled in some circumstances when used with drf views, remove it (#5408) + state.pop( + "plugin", None + ) # plugin cannot be pickled in some circumstances when used with drf views, remove it (#5408) return state def save(self, force_insert=False, force_update=False, *args, **kwargs): @@ -160,9 +170,8 @@ class PluginSetting(common.models.BaseInvenTreeSetting): class Meta: """Meta for PluginSetting.""" - unique_together = [ - ('plugin', 'key'), - ] + + unique_together = [('plugin', 'key')] plugin = models.ForeignKey( PluginConfig, @@ -185,7 +194,6 @@ class PluginSetting(common.models.BaseInvenTreeSetting): (if the plugin is specified!) """ if 'settings' not in kwargs: - plugin = kwargs.pop('plugin', None) if plugin: @@ -204,9 +212,8 @@ class NotificationUserSetting(common.models.BaseInvenTreeSetting): class Meta: """Meta for NotificationUserSetting.""" - unique_together = [ - ('method', 'user', 'key'), - ] + + unique_together = [('method', 'user', 'key')] @classmethod def get_setting_definition(cls, key, **kwargs): @@ -217,15 +224,13 @@ class NotificationUserSetting(common.models.BaseInvenTreeSetting): return super().get_setting_definition(key, **kwargs) - method = models.CharField( - max_length=255, - verbose_name=_('Method'), - ) + method = models.CharField(max_length=255, verbose_name=_('Method')) user = models.ForeignKey( User, on_delete=models.CASCADE, - blank=True, null=True, + blank=True, + null=True, verbose_name=_('User'), help_text=_('User'), ) diff --git a/InvenTree/plugin/plugin.py b/InvenTree/plugin/plugin.py index 1938f59a2a..1cf5b28c9d 100644 --- a/InvenTree/plugin/plugin.py +++ b/InvenTree/plugin/plugin.py @@ -46,7 +46,11 @@ class MetaBase: # Sound of a warning if old_key worked if value: - warnings.warn(f'Usage of {old_key} was depreciated in 0.7.0 in favour of {key}', DeprecationWarning, stacklevel=2) + warnings.warn( + f'Usage of {old_key} was depreciated in 0.7.0 in favour of {key}', + DeprecationWarning, + stacklevel=2, + ) # Use __default if still nothing set if (value is None) and __default: @@ -96,8 +100,7 @@ class MetaBase: import plugin.models cfg, _ = plugin.models.PluginConfig.objects.get_or_create( - key=self.plugin_slug(), - name=self.plugin_name(), + key=self.plugin_slug(), name=self.plugin_name() ) except (OperationalError, ProgrammingError): cfg = None @@ -153,14 +156,14 @@ class MixinBase: def setup_mixin(self, key, cls=None): """Define mixin details for the current mixin -> provides meta details for all active mixins.""" # get human name - human_name = getattr(cls.MixinMeta, 'MIXIN_NAME', key) if cls and hasattr(cls, 'MixinMeta') else key + human_name = ( + getattr(cls.MixinMeta, 'MIXIN_NAME', key) + if cls and hasattr(cls, 'MixinMeta') + else key + ) # register - self._mixinreg[key] = { - 'key': key, - 'human_name': human_name, - 'cls': cls, - } + self._mixinreg[key] = {'key': key, 'human_name': human_name, 'cls': cls} def get_registered_mixins(self, with_base: bool = False, with_cls: bool = True): """Get all registered mixins for the plugin.""" @@ -175,7 +178,10 @@ class MixinBase: # Do not return the mixin class if flas is set if not with_cls: - return {key: {k: v for k, v in mixin.items() if k != 'cls'} for key, mixin in mixins.items()} + return { + key: {k: v for k, v in mixin.items() if k != 'cls'} + for key, mixin in mixins.items() + } return mixins @property @@ -290,6 +296,7 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase): def license(self): """License of plugin.""" return self._get_value('LICENSE', 'license') + # endregion @classmethod @@ -363,7 +370,6 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase): meta = metadata(cls.__name__) # Simple lookup did not work - get data from module except PackageNotFoundError: - try: meta = metadata(cls.__module__.split('.')[0]) except PackageNotFoundError: @@ -372,7 +378,7 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase): try: website = meta['Project-URL'].split(', ')[1] - except (ValueError, IndexError, AttributeError, ): + except (ValueError, IndexError, AttributeError): website = meta['Project-URL'] return { @@ -380,13 +386,17 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase): 'description': meta['Summary'], 'version': meta['Version'], 'website': website, - 'license': meta['License'] + 'license': meta['License'], } def define_package(self): """Add package info of the plugin into plugins context.""" try: - package = self._get_package_metadata() if self._is_package else self._get_package_commit() + package = ( + self._get_package_metadata() + if self._is_package + else self._get_package_commit() + ) except TypeError: package = {} @@ -396,4 +406,5 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase): # set variables self.package = package + # endregion diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index b6b749f168..5fa890bf34 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -21,14 +21,22 @@ from django.urls import clear_url_caches, path from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ -from maintenance_mode.core import (get_maintenance_mode, maintenance_mode_on, - set_maintenance_mode) +from maintenance_mode.core import ( + get_maintenance_mode, + maintenance_mode_on, + set_maintenance_mode, +) from InvenTree.config import get_plugin_dir from InvenTree.ready import canAppAccessDatabase -from .helpers import (IntegrationPluginError, get_entrypoints, get_plugins, - handle_error, log_error) +from .helpers import ( + IntegrationPluginError, + get_entrypoints, + get_plugins, + handle_error, + log_error, +) from .plugin import InvenTreePlugin logger = logging.getLogger('inventree') @@ -50,25 +58,31 @@ class PluginsRegistry: Set up all needed references for internal and external states. """ # plugin registry - self.plugins: Dict[str, InvenTreePlugin] = {} # List of active instances - self.plugins_inactive: Dict[str, InvenTreePlugin] = {} # List of inactive instances - self.plugins_full: Dict[str, InvenTreePlugin] = {} # List of all plugin instances + self.plugins: Dict[str, InvenTreePlugin] = {} # List of active instances + self.plugins_inactive: Dict[ + str, InvenTreePlugin + ] = {} # List of inactive instances + self.plugins_full: Dict[ + str, InvenTreePlugin + ] = {} # List of all plugin instances # Keep an internal hash of the plugin registry state self.registry_hash = None - self.plugin_modules: List[InvenTreePlugin] = [] # Holds all discovered plugins - self.mixin_modules: Dict[str, Any] = {} # Holds all discovered mixins + self.plugin_modules: List[InvenTreePlugin] = [] # Holds all discovered plugins + self.mixin_modules: Dict[str, Any] = {} # Holds all discovered mixins - self.errors = {} # Holds discovering errors + self.errors = {} # Holds discovering errors - self.loading_lock = Lock() # Lock to prevent multiple loading at the same time + self.loading_lock = Lock() # Lock to prevent multiple loading at the same time # flags - self.plugins_loaded = False # Marks if the registry fully loaded and all django apps are reloaded - self.apps_loading = True # Marks if apps were reloaded yet + self.plugins_loaded = ( + False # Marks if the registry fully loaded and all django apps are reloaded + ) + self.apps_loading = True # Marks if apps were reloaded yet - self.installed_apps = [] # Holds all added plugin_paths + self.installed_apps = [] # Holds all added plugin_paths @property def is_loading(self): @@ -134,7 +148,6 @@ class PluginsRegistry: for plugin in self.plugins.values(): if plugin.mixin_enabled(mixin): - if active is not None: # Filter by 'active' status of plugin if active != plugin.is_active(): @@ -148,6 +161,7 @@ class PluginsRegistry: result.append(plugin) return result + # endregion # region loading / unloading @@ -179,7 +193,11 @@ class PluginsRegistry: logger.info('Database not accessible while loading plugins') break except IntegrationPluginError as error: - logger.exception('[PLUGIN] Encountered an error with %s:\n%s', error.path, error.message) + logger.exception( + '[PLUGIN] Encountered an error with %s:\n%s', + error.path, + error.message, + ) log_error({error.path: error.message}, 'load') blocked_plugin = error.path # we will not try to load this app again @@ -196,7 +214,9 @@ class PluginsRegistry: print('[PLUGIN] Max retries, breaking loading') break if settings.PLUGIN_TESTING: - print(f'[PLUGIN] Above error occurred during testing - {retry_counter}/{settings.PLUGIN_RETRY} retries left') + print( + f'[PLUGIN] Above error occurred during testing - {retry_counter}/{settings.PLUGIN_RETRY} retries left' + ) # now the loading will re-start up with init @@ -216,6 +236,7 @@ class PluginsRegistry: # Trigger plugins_loaded event if canAppAccessDatabase(): from plugin.events import trigger_event + trigger_event('plugins_loaded') def _unload_plugins(self, force_reload: bool = False): @@ -243,7 +264,12 @@ class PluginsRegistry: logger.info('Finished unloading plugins') - def reload_plugins(self, full_reload: bool = False, force_reload: bool = False, collect: bool = False): + def reload_plugins( + self, + full_reload: bool = False, + force_reload: bool = False, + collect: bool = False, + ): """Reload the plugin registry. This should be considered the single point of entry for loading plugins! @@ -259,8 +285,12 @@ class PluginsRegistry: return if self.loading_lock.acquire(blocking=False): - - logger.info('Plugin Registry: Reloading plugins - Force: %s, Full: %s, Collect: %s', force_reload, full_reload, collect) + logger.info( + 'Plugin Registry: Reloading plugins - Force: %s, Full: %s, Collect: %s', + force_reload, + full_reload, + collect, + ) with maintenance_mode_on(): if collect: @@ -280,7 +310,7 @@ class PluginsRegistry: def plugin_dirs(self): """Construct a list of directories from where plugins can be loaded""" # Builtin plugins are *always* loaded - dirs = ['plugin.builtin', ] + dirs = ['plugin.builtin'] if settings.PLUGINS_ENABLED: # Any 'external' plugins are only loaded if PLUGINS_ENABLED is set to True @@ -307,7 +337,9 @@ class PluginsRegistry: try: pd.mkdir(exist_ok=True) except Exception: # pragma: no cover - logger.exception("Could not create plugin directory '%s'", pd) + logger.exception( + "Could not create plugin directory '%s'", pd + ) continue # Ensure the directory has an __init__.py file @@ -317,7 +349,9 @@ class PluginsRegistry: try: init_filename.write_text("# InvenTree plugin directory\n") except Exception: # pragma: no cover - logger.exception("Could not create file '%s'", init_filename) + logger.exception( + "Could not create file '%s'", init_filename + ) continue # By this point, we have confirmed that the directory at least exists @@ -340,7 +374,6 @@ class PluginsRegistry: # Collect plugins from paths for plugin in self.plugin_dirs(): - logger.debug("Loading plugins from directory '%s'", plugin) parent_path = None @@ -354,7 +387,9 @@ class PluginsRegistry: # Gather Modules if parent_path: - raw_module = imp.load_source(plugin, str(parent_obj.joinpath('__init__.py'))) + raw_module = imp.load_source( + plugin, str(parent_obj.joinpath('__init__.py')) + ) else: raw_module = importlib.import_module(plugin) modules = get_plugins(raw_module, InvenTreePlugin, path=parent_path) @@ -364,9 +399,10 @@ class PluginsRegistry: # From this point any plugins are considered "external" and only loaded if plugins are explicitly enabled if settings.PLUGINS_ENABLED: - # Check if not running in testing mode and apps should be loaded from hooks - if (not settings.PLUGIN_TESTING) or (settings.PLUGIN_TESTING and settings.PLUGIN_TESTING_SETUP): + if (not settings.PLUGIN_TESTING) or ( + settings.PLUGIN_TESTING and settings.PLUGIN_TESTING_SETUP + ): # Collect plugins from setup entry points for entry in get_entrypoints(): try: @@ -436,15 +472,15 @@ class PluginsRegistry: logger.debug('Starting plugin initialization') # Fetch and cache list of existing plugin configuration instances - plugin_configs = { - cfg.key: cfg for cfg in PluginConfig.objects.all() - } + plugin_configs = {cfg.key: cfg for cfg in PluginConfig.objects.all()} # Initialize plugins for plg in self.plugin_modules: # These checks only use attributes - never use plugin supplied functions -> that would lead to arbitrary code execution!! plg_name = plg.NAME - plg_key = slugify(plg.SLUG if getattr(plg, 'SLUG', None) else plg_name) # keys are slugs! + plg_key = slugify( + plg.SLUG if getattr(plg, 'SLUG', None) else plg_name + ) # keys are slugs! try: if plg_key in plugin_configs: @@ -452,13 +488,15 @@ class PluginsRegistry: plg_db = plugin_configs[plg_key] else: # Configuration needs to be created - plg_db, _created = PluginConfig.objects.get_or_create(key=plg_key, name=plg_name) + plg_db, _created = PluginConfig.objects.get_or_create( + key=plg_key, name=plg_name + ) except (OperationalError, ProgrammingError) as error: # Exception if the database has not been migrated yet - check if test are running - raise if not if not settings.PLUGIN_TESTING: raise error # pragma: no cover plg_db = None - except (IntegrityError) as error: # pragma: no cover + except IntegrityError as error: # pragma: no cover logger.exception("Error initializing plugin `%s`: %s", plg_name, error) handle_error(error, log_name='init') @@ -492,7 +530,9 @@ class PluginsRegistry: dt = time.time() - t_start logger.debug('Loaded plugin `%s` in %.3fs', plg_name, dt) except Exception as error: - handle_error(error, log_name='init') # log error and raise it -> disable plugin + handle_error( + error, log_name='init' + ) # log error and raise it -> disable plugin logger.warning("Plugin `%s` could not be loaded", plg_name) # Safe extra attributes @@ -501,13 +541,17 @@ class PluginsRegistry: plg_i.db = plg_db # Run version check for plugin - if (plg_i.MIN_VERSION or plg_i.MAX_VERSION) and not plg_i.check_version(): + if ( + plg_i.MIN_VERSION or plg_i.MAX_VERSION + ) and not plg_i.check_version(): # Disable plugin safe_reference(plugin=plg_i, key=plg_key, active=False) p = plg_name v = version.inventreeVersion() - _msg = _(f"Plugin '{p}' is not compatible with the current InvenTree version {v}") + _msg = _( + f"Plugin '{p}' is not compatible with the current InvenTree version {v}" + ) if v := plg_i.MIN_VERSION: _msg += _(f'Plugin requires at least version {v}') if v := plg_i.MAX_VERSION: @@ -525,7 +569,11 @@ class PluginsRegistry: order = self.DEFAULT_MIXIN_ORDER # Append mixins that are not defined in the default list - order += [m.get('cls') for m in self.mixin_modules.values() if m.get('cls') not in order] + order += [ + m.get('cls') + for m in self.mixin_modules.values() + if m.get('cls') not in order + ] # Final list of mixins return order @@ -546,7 +594,9 @@ class PluginsRegistry: for mixin in self.__get_mixin_order(): if hasattr(mixin, '_activate_mixin'): - mixin._activate_mixin(self, plugins, force_reload=force_reload, full_reload=full_reload) + mixin._activate_mixin( + self, plugins, force_reload=force_reload, full_reload=full_reload + ) logger.debug('Done activating') @@ -561,6 +611,7 @@ class PluginsRegistry: mixin._deactivate_mixin(self, force_reload=force_reload) logger.debug('Finished deactivating plugins') + # endregion # region mixin specific loading ... @@ -617,13 +668,14 @@ class PluginsRegistry: from plugin.urls import get_plugin_urls for index, url in enumerate(urlpatterns): - app_name = getattr(url, 'app_name', None) admin_url = settings.INVENTREE_ADMIN_URL if app_name == 'admin': - urlpatterns[index] = path(f'{admin_url}/', admin.site.urls, name='inventree-admin') + urlpatterns[index] = path( + f'{admin_url}/', admin.site.urls, name='inventree-admin' + ) if app_name == 'plugin': urlpatterns[index] = get_plugin_urls() @@ -641,14 +693,20 @@ class PluginsRegistry: self.registry_hash = self.calculate_plugin_hash() try: - old_hash = InvenTreeSetting.get_setting("_PLUGIN_REGISTRY_HASH", "", create=False, cache=False) + old_hash = InvenTreeSetting.get_setting( + "_PLUGIN_REGISTRY_HASH", "", create=False, cache=False + ) except Exception: old_hash = "" if old_hash != self.registry_hash: try: - logger.debug("Updating plugin registry hash: %s", str(self.registry_hash)) - InvenTreeSetting.set_setting("_PLUGIN_REGISTRY_HASH", self.registry_hash, change_user=None) + logger.debug( + "Updating plugin registry hash: %s", str(self.registry_hash) + ) + InvenTreeSetting.set_setting( + "_PLUGIN_REGISTRY_HASH", self.registry_hash, change_user=None + ) except (OperationalError, ProgrammingError): # Exception if the database has not been migrated yet, or is not ready pass @@ -680,12 +738,18 @@ class PluginsRegistry: 'ENABLE_PLUGINS_NAVIGATION', 'ENABLE_PLUGINS_APP', 'ENABLE_PLUGINS_SCHEDULE', - 'ENABLE_PLUGINS_EVENTS' + 'ENABLE_PLUGINS_EVENTS', ] for k in keys: try: - data.update(str(InvenTreeSetting.get_setting(k, False, cache=False, create=False)).encode()) + data.update( + str( + InvenTreeSetting.get_setting( + k, False, cache=False, create=False + ) + ).encode() + ) except Exception: pass @@ -710,7 +774,9 @@ class PluginsRegistry: self.registry_hash = self.calculate_plugin_hash() try: - reg_hash = InvenTreeSetting.get_setting("_PLUGIN_REGISTRY_HASH", "", create=False, cache=False) + reg_hash = InvenTreeSetting.get_setting( + "_PLUGIN_REGISTRY_HASH", "", create=False, cache=False + ) except Exception as exc: logger.exception("Failed to retrieve plugin registry hash: %s", str(exc)) return diff --git a/InvenTree/plugin/samples/event/test_event_sample.py b/InvenTree/plugin/samples/event/test_event_sample.py index e466aaa0eb..f1a8d103f0 100644 --- a/InvenTree/plugin/samples/event/test_event_sample.py +++ b/InvenTree/plugin/samples/event/test_event_sample.py @@ -29,7 +29,9 @@ class EventPluginSampleTests(TestCase): # Check that an event is issued with self.assertLogs(logger=logger, level="DEBUG") as cm: trigger_event('test.event') - self.assertIn('DEBUG:inventree:Event `test.event` triggered in sample plugin', cm[1]) + self.assertIn( + 'DEBUG:inventree:Event `test.event` triggered in sample plugin', cm[1] + ) # Disable again settings.PLUGIN_TESTING_EVENTS = False @@ -37,6 +39,7 @@ class EventPluginSampleTests(TestCase): def test_mixin(self): """Test that MixinNotImplementedError is raised.""" with self.assertRaises(MixinNotImplementedError): + class Wrong(EventMixin, InvenTreePlugin): pass diff --git a/InvenTree/plugin/samples/event/test_filtered_event_sample.py b/InvenTree/plugin/samples/event/test_filtered_event_sample.py index efcb5c0290..fce2254785 100644 --- a/InvenTree/plugin/samples/event/test_filtered_event_sample.py +++ b/InvenTree/plugin/samples/event/test_filtered_event_sample.py @@ -27,7 +27,9 @@ class FilteredEventPluginSampleTests(TestCase): # Check that an event is issued with self.assertLogs(logger=logger, level="DEBUG") as cm: trigger_event('test.event') - self.assertIn('DEBUG:inventree:Event `test.event` triggered in sample plugin', cm[1]) + self.assertIn( + 'DEBUG:inventree:Event `test.event` triggered in sample plugin', cm[1] + ) # Disable again settings.PLUGIN_TESTING_EVENTS = False @@ -46,7 +48,10 @@ class FilteredEventPluginSampleTests(TestCase): # Check that an event is issued with self.assertLogs(logger=logger, level="DEBUG") as cm: trigger_event('test.some.other.event') - self.assertNotIn('DEBUG:inventree:Event `test.some.other.event` triggered in sample plugin', cm[1]) + self.assertNotIn( + 'DEBUG:inventree:Event `test.some.other.event` triggered in sample plugin', + cm[1], + ) # Disable again settings.PLUGIN_TESTING_EVENTS = False diff --git a/InvenTree/plugin/samples/integration/another_sample.py b/InvenTree/plugin/samples/integration/another_sample.py index 14f92bf583..9891584ab2 100644 --- a/InvenTree/plugin/samples/integration/another_sample.py +++ b/InvenTree/plugin/samples/integration/another_sample.py @@ -1,4 +1,5 @@ """Sample implementation for IntegrationPlugin.""" + from plugin import InvenTreePlugin from plugin.mixins import UrlsMixin diff --git a/InvenTree/plugin/samples/integration/api_caller.py b/InvenTree/plugin/samples/integration/api_caller.py index 0968780e8b..ca704a839e 100644 --- a/InvenTree/plugin/samples/integration/api_caller.py +++ b/InvenTree/plugin/samples/integration/api_caller.py @@ -1,4 +1,5 @@ """Sample plugin for calling an external API.""" + from plugin import InvenTreePlugin from plugin.mixins import APICallMixin, SettingsMixin @@ -9,10 +10,7 @@ class SampleApiCallerPlugin(APICallMixin, SettingsMixin, InvenTreePlugin): NAME = "Sample API Caller" SETTINGS = { - 'API_TOKEN': { - 'name': 'API Token', - 'protected': True, - }, + 'API_TOKEN': {'name': 'API Token', 'protected': True}, 'API_URL': { 'name': 'External URL', 'description': 'Where is your API located?', diff --git a/InvenTree/plugin/samples/integration/custom_panel_sample.py b/InvenTree/plugin/samples/integration/custom_panel_sample.py index 0ef2086029..695816ce47 100644 --- a/InvenTree/plugin/samples/integration/custom_panel_sample.py +++ b/InvenTree/plugin/samples/integration/custom_panel_sample.py @@ -27,7 +27,7 @@ class CustomPanelSample(PanelMixin, SettingsMixin, InvenTreePlugin): 'description': 'Enable a panel with rendering issues', 'default': False, 'validator': bool, - } + }, } def get_panel_context(self, view, request, context): @@ -51,12 +51,11 @@ class CustomPanelSample(PanelMixin, SettingsMixin, InvenTreePlugin): panels = [ { # Simple panel without any actual content - 'title': 'No Content', + 'title': 'No Content' } ] if self.get_setting('ENABLE_HELLO_WORLD'): - # We can use template rendering in the raw content content = """ Hello world! @@ -81,7 +80,6 @@ class CustomPanelSample(PanelMixin, SettingsMixin, InvenTreePlugin): }) if self.get_setting('ENABLE_BROKEN_PANEL'): - # Enabling this panel will cause panel rendering to break, # due to the invalid tags panels.append({ @@ -103,7 +101,6 @@ class CustomPanelSample(PanelMixin, SettingsMixin, InvenTreePlugin): # This panel will *only* display on the StockLocation view, # and *only* if the StockLocation has *no* child locations if isinstance(view, StockLocationDetail): - try: loc = view.get_object() diff --git a/InvenTree/plugin/samples/integration/label_sample.py b/InvenTree/plugin/samples/integration/label_sample.py index e35f6ee6f1..dbf5a65700 100644 --- a/InvenTree/plugin/samples/integration/label_sample.py +++ b/InvenTree/plugin/samples/integration/label_sample.py @@ -21,6 +21,7 @@ class SampleLabelPrinter(LabelPrintingMixin, InvenTreePlugin): class PrintingOptionsSerializer(serializers.Serializer): """Serializer to return printing options.""" + amount = serializers.IntegerField(required=False, default=1) def print_label(self, **kwargs): diff --git a/InvenTree/plugin/samples/integration/report_plugin_sample.py b/InvenTree/plugin/samples/integration/report_plugin_sample.py index 5b143ab307..7c8dbaa908 100644 --- a/InvenTree/plugin/samples/integration/report_plugin_sample.py +++ b/InvenTree/plugin/samples/integration/report_plugin_sample.py @@ -24,7 +24,9 @@ class SampleReportPlugin(ReportMixin, InvenTreePlugin): """Add example content to the report instance""" # We can add any extra context data we want to the report # Generate a random string of data - context['random_text'] = ''.join(random.choices('abcdefghijklmnopqrstuvwxyz', k=20)) + context['random_text'] = ''.join( + random.choices('abcdefghijklmnopqrstuvwxyz', k=20) + ) # Call a custom method context['random_int'] = self.some_custom_function() diff --git a/InvenTree/plugin/samples/integration/sample.py b/InvenTree/plugin/samples/integration/sample.py index 3f58261ec3..efdc342b52 100644 --- a/InvenTree/plugin/samples/integration/sample.py +++ b/InvenTree/plugin/samples/integration/sample.py @@ -19,7 +19,9 @@ def validate_json(value): raise ValidationError(str(e)) -class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, InvenTreePlugin): +class SampleIntegrationPlugin( + AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, InvenTreePlugin +): """A full plugin example.""" NAME = "SampleIntegrationPlugin" @@ -66,12 +68,7 @@ class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixi 'CHOICE_SETTING': { 'name': _("Choice Setting"), 'description': _('A setting with multiple choices'), - 'choices': [ - ('A', 'Anaconda'), - ('B', 'Bat'), - ('C', 'Cat'), - ('D', 'Dog'), - ], + 'choices': [('A', 'Anaconda'), ('B', 'Bat'), ('C', 'Cat'), ('D', 'Dog')], 'default': 'A', }, 'SELECT_COMPANY': { @@ -94,9 +91,7 @@ class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixi 'name': 'JSON validator Setting', 'description': 'A setting using a JSON validator', 'validator': validate_json, - } + }, } - NAVIGATION = [ - {'name': 'SampleIntegration', 'link': 'plugin:sample:hi'}, - ] + NAVIGATION = [{'name': 'SampleIntegration', 'link': 'plugin:sample:hi'}] diff --git a/InvenTree/plugin/samples/integration/sample_currency_exchange.py b/InvenTree/plugin/samples/integration/sample_currency_exchange.py index 7a490543da..b26a438c58 100644 --- a/InvenTree/plugin/samples/integration/sample_currency_exchange.py +++ b/InvenTree/plugin/samples/integration/sample_currency_exchange.py @@ -19,9 +19,7 @@ class SampleCurrencyExchangePlugin(CurrencyExchangeMixin, InvenTreePlugin): def update_exchange_rates(self, base_currency: str, symbols: list[str]) -> dict: """Return dummy data for some currencies""" - rates = { - base_currency: 1.00, - } + rates = {base_currency: 1.00} for symbol in symbols: rates[symbol] = random.randrange(5, 15) * 0.1 diff --git a/InvenTree/plugin/samples/integration/scheduled_task.py b/InvenTree/plugin/samples/integration/scheduled_task.py index 50c7fd8d97..d2e4240877 100644 --- a/InvenTree/plugin/samples/integration/scheduled_task.py +++ b/InvenTree/plugin/samples/integration/scheduled_task.py @@ -30,11 +30,7 @@ class ScheduledTaskPlugin(ScheduleMixin, SettingsMixin, InvenTreePlugin): VERSION = "0.2.0" SCHEDULED_TASKS = { - 'member': { - 'func': 'member_func', - 'schedule': 'I', - 'minutes': 30, - }, + 'member': {'func': 'member_func', 'schedule': 'I', 'minutes': 30}, 'hello': { 'func': 'plugin.samples.integration.scheduled_task.print_hello', 'schedule': 'I', @@ -52,7 +48,7 @@ class ScheduledTaskPlugin(ScheduleMixin, SettingsMixin, InvenTreePlugin): 'description': 'Print true or false when running the periodic task', 'validator': bool, 'default': False, - }, + } } def member_func(self, *args, **kwargs): diff --git a/InvenTree/plugin/samples/integration/simpleactionplugin.py b/InvenTree/plugin/samples/integration/simpleactionplugin.py index af4b404764..4e4b82f13d 100644 --- a/InvenTree/plugin/samples/integration/simpleactionplugin.py +++ b/InvenTree/plugin/samples/integration/simpleactionplugin.py @@ -16,10 +16,7 @@ class SimpleActionPlugin(ActionMixin, InvenTreePlugin): def get_info(self, user, data=None): """Sample method.""" - return { - "user": user.username, - "hello": "world", - } + return {"user": user.username, "hello": "world"} def get_result(self, user=None, data=None): """Sample method.""" diff --git a/InvenTree/plugin/samples/integration/test_api_caller.py b/InvenTree/plugin/samples/integration/test_api_caller.py index 28b51835c8..ff07af0bca 100644 --- a/InvenTree/plugin/samples/integration/test_api_caller.py +++ b/InvenTree/plugin/samples/integration/test_api_caller.py @@ -18,4 +18,4 @@ class SampleApiCallerPluginTests(TestCase): # do an api call result = plg.get_external_url() self.assertTrue(result) - self.assertIn('data', result,) + self.assertIn('data', result) diff --git a/InvenTree/plugin/samples/integration/test_sample.py b/InvenTree/plugin/samples/integration/test_sample.py index 66a6106f76..18e15f8721 100644 --- a/InvenTree/plugin/samples/integration/test_sample.py +++ b/InvenTree/plugin/samples/integration/test_sample.py @@ -50,6 +50,7 @@ class SampleIntegrationPluginTests(InvenTreeTestCase): self.assertEqual(plugin.check_settings(), (True, [])) # validator + def test_settings_validator(self): """Test settings validator for plugins.""" plugin = registry.get_plugin('sample') diff --git a/InvenTree/plugin/samples/integration/test_scheduled_task.py b/InvenTree/plugin/samples/integration/test_scheduled_task.py index 7e433178f1..6a9518a917 100644 --- a/InvenTree/plugin/samples/integration/test_scheduled_task.py +++ b/InvenTree/plugin/samples/integration/test_scheduled_task.py @@ -22,12 +22,20 @@ class ExampleScheduledTaskPluginTests(TestCase): self.assertEqual(plg.member_func(), False) # check that the tasks are defined - self.assertEqual(plg.get_task_names(), ['plugin.schedule.member', 'plugin.schedule.hello', 'plugin.schedule.world']) + self.assertEqual( + plg.get_task_names(), + [ + 'plugin.schedule.member', + 'plugin.schedule.hello', + 'plugin.schedule.world', + ], + ) # register plg.register_tasks() # check that schedule was registers from django_q.models import Schedule + scheduled_plugin_tasks = Schedule.objects.filter(name__istartswith="plugin.") self.assertEqual(len(scheduled_plugin_tasks), 3) @@ -70,11 +78,13 @@ class ScheduledTaskPluginTests(TestCase): def test_init(self): """Check that all MixinImplementationErrors raise.""" + class Base(ScheduleMixin, InvenTreePlugin): NAME = 'APlugin' class NoSchedules(Base): """Plugin without schedules.""" + pass with self.assertRaises(MixinImplementationError): @@ -86,12 +96,7 @@ class ScheduledTaskPluginTests(TestCase): This plugin is missing a func """ - SCHEDULED_TASKS = { - 'test': { - 'schedule': 'I', - 'minutes': 30, - }, - } + SCHEDULED_TASKS = {'test': {'schedule': 'I', 'minutes': 30}} def test(self): pass # pragma: no cover @@ -105,12 +110,7 @@ class ScheduledTaskPluginTests(TestCase): This plugin is missing a schedule """ - SCHEDULED_TASKS = { - 'test': { - 'func': 'test', - 'minutes': 30, - }, - } + SCHEDULED_TASKS = {'test': {'func': 'test', 'minutes': 30}} with self.assertRaises(MixinImplementationError): WrongFuncSchedules1() @@ -121,12 +121,7 @@ class ScheduledTaskPluginTests(TestCase): This plugin is missing a schedule """ - SCHEDULED_TASKS = { - 'test': { - 'func': 'test', - 'minutes': 30, - }, - } + SCHEDULED_TASKS = {'test': {'func': 'test', 'minutes': 30}} with self.assertRaises(MixinImplementationError): WrongFuncSchedules2() @@ -138,11 +133,7 @@ class ScheduledTaskPluginTests(TestCase): """ SCHEDULED_TASKS = { - 'test': { - 'func': 'test', - 'schedule': 'XX', - 'minutes': 30, - }, + 'test': {'func': 'test', 'schedule': 'XX', 'minutes': 30} } with self.assertRaises(MixinImplementationError): @@ -154,12 +145,7 @@ class ScheduledTaskPluginTests(TestCase): This plugin is missing a minute marker for its schedule """ - SCHEDULED_TASKS = { - 'test': { - 'func': 'test', - 'schedule': 'I', - }, - } + SCHEDULED_TASKS = {'test': {'func': 'test', 'schedule': 'I'}} with self.assertRaises(MixinImplementationError): WrongFuncSchedules4() diff --git a/InvenTree/plugin/samples/integration/test_simpleactionplugin.py b/InvenTree/plugin/samples/integration/test_simpleactionplugin.py index 2599984a66..c6205be7e4 100644 --- a/InvenTree/plugin/samples/integration/test_simpleactionplugin.py +++ b/InvenTree/plugin/samples/integration/test_simpleactionplugin.py @@ -21,16 +21,15 @@ class SimpleActionPluginTests(InvenTreeTestCase): def test_function(self): """Check if functions work.""" # test functions - response = self.client.post('/api/action/', data={'action': "simple", 'data': {'foo': "bar", }}) + response = self.client.post( + '/api/action/', data={'action': "simple", 'data': {'foo': "bar"}} + ) self.assertEqual(response.status_code, 200) self.assertJSONEqual( str(response.content, encoding='utf8'), { "action": 'simple', "result": True, - "info": { - "user": self.username, - "hello": "world", - }, - } + "info": {"user": self.username, "hello": "world"}, + }, ) diff --git a/InvenTree/plugin/samples/integration/transition.py b/InvenTree/plugin/samples/integration/transition.py index 4de3b140a9..bed1a91a5a 100644 --- a/InvenTree/plugin/samples/integration/transition.py +++ b/InvenTree/plugin/samples/integration/transition.py @@ -18,7 +18,9 @@ class SampleTransitionPlugin(InvenTreePlugin): def transition(current_state, target_state, instance, default_action, **kwargs): # noqa: N805 """Example override function for state transition.""" # Only act on ReturnOrders that should be completed - if not isinstance(instance, ReturnOrder) or not (target_state == ReturnOrderStatus.COMPLETE.value): + if not isinstance(instance, ReturnOrder) or not ( + target_state == ReturnOrderStatus.COMPLETE.value + ): return False # Only allow proceeding if the return order has a responsible user assigned @@ -28,8 +30,10 @@ class SampleTransitionPlugin(InvenTreePlugin): trigger_notification( instance, 'sampel_123_456', - targets=[instance.created_by, ], - context={'message': "Return order without responsible owner can not be completed!"}, + targets=[instance.created_by], + context={ + 'message': "Return order without responsible owner can not be completed!" + }, ) return True # True means nothing will happen return False # Do not act diff --git a/InvenTree/plugin/samples/integration/validation_sample.py b/InvenTree/plugin/samples/integration/validation_sample.py index 63e8d0eb9b..67b84b636b 100644 --- a/InvenTree/plugin/samples/integration/validation_sample.py +++ b/InvenTree/plugin/samples/integration/validation_sample.py @@ -24,7 +24,7 @@ class CustomValidationMixin(SettingsMixin, ValidationMixin, InvenTreePlugin): 'ILLEGAL_PART_CHARS': { 'name': 'Illegal Part Characters', 'description': 'Characters which are not allowed to appear in Part names', - 'default': '!@#$%^&*()~`' + 'default': '!@#$%^&*()~`', }, 'IPN_MUST_CONTAIN_Q': { 'name': 'IPN Q Requirement', @@ -98,7 +98,9 @@ class CustomValidationMixin(SettingsMixin, ValidationMixin, InvenTreePlugin): if self.get_setting('SERIAL_MUST_MATCH_PART'): # Serial must start with the same letter as the linked part, for some reason if serial[0] != part.name[0]: - raise ValidationError("Serial number must start with same letter as part") + raise ValidationError( + "Serial number must start with same letter as part" + ) def validate_batch_code(self, batch_code: str, item): """Ensure that a particular batch code meets specification. diff --git a/InvenTree/plugin/samples/integration/version.py b/InvenTree/plugin/samples/integration/version.py index d0f2183310..78f9ccec73 100644 --- a/InvenTree/plugin/samples/integration/version.py +++ b/InvenTree/plugin/samples/integration/version.py @@ -1,4 +1,5 @@ """Sample plugin for versioning.""" + from plugin import InvenTreePlugin diff --git a/InvenTree/plugin/samples/locate/locate_sample.py b/InvenTree/plugin/samples/locate/locate_sample.py index 13c7f4e304..2a53bf79f6 100644 --- a/InvenTree/plugin/samples/locate/locate_sample.py +++ b/InvenTree/plugin/samples/locate/locate_sample.py @@ -51,7 +51,9 @@ class SampleLocatePlugin(LocateMixin, InvenTreePlugin): """ from stock.models import StockLocation - logger.info("SampleLocatePlugin attempting to locate location ID %s", location_pk) + logger.info( + "SampleLocatePlugin attempting to locate location ID %s", location_pk + ) try: location = StockLocation.objects.get(pk=location_pk) diff --git a/InvenTree/plugin/samples/locate/test_locate_sample.py b/InvenTree/plugin/samples/locate/test_locate_sample.py index 04f1d674bc..e34d0de0ae 100644 --- a/InvenTree/plugin/samples/locate/test_locate_sample.py +++ b/InvenTree/plugin/samples/locate/test_locate_sample.py @@ -11,12 +11,7 @@ from plugin.mixins import LocateMixin class SampleLocatePlugintests(InvenTreeAPITestCase): """Tests for SampleLocatePlugin.""" - fixtures = [ - 'location', - 'category', - 'part', - 'stock' - ] + fixtures = ['location', 'category', 'part', 'stock'] def test_run_locator(self): """Check if the event is issued.""" @@ -53,6 +48,7 @@ class SampleLocatePlugintests(InvenTreeAPITestCase): """Test that MixinNotImplementedError is raised.""" # Test location locator with self.assertRaises(MixinNotImplementedError): + class Wrong(LocateMixin, InvenTreePlugin): pass diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py index e86dc2b3fe..57d17e6dd0 100644 --- a/InvenTree/plugin/serializers.py +++ b/InvenTree/plugin/serializers.py @@ -17,9 +17,7 @@ class MetadataSerializer(serializers.ModelSerializer): class Meta: """Metaclass options.""" - fields = [ - 'metadata', - ] + fields = ['metadata'] def __init__(self, model_type, *args, **kwargs): """Initialize the metadata serializer with information on the model type""" @@ -46,6 +44,7 @@ class PluginConfigSerializer(serializers.ModelSerializer): class Meta: """Meta for serializer.""" + model = PluginConfig fields = [ 'pk', @@ -59,12 +58,7 @@ class PluginConfigSerializer(serializers.ModelSerializer): 'is_installed', ] - read_only_fields = [ - 'key', - 'is_builtin', - 'is_sample', - 'is_installed', - ] + read_only_fields = ['key', 'is_builtin', 'is_sample', 'is_installed'] meta = serializers.DictField(read_only=True) mixins = serializers.DictField(read_only=True) @@ -75,27 +69,30 @@ class PluginConfigInstallSerializer(serializers.Serializer): class Meta: """Meta for serializer.""" - fields = [ - 'url', - 'packagename', - 'confirm', - ] + + fields = ['url', 'packagename', 'confirm'] url = serializers.CharField( required=False, allow_blank=True, label=_('Source URL'), - help_text=_('Source for the package - this can be a custom registry or a VCS path') + help_text=_( + 'Source for the package - this can be a custom registry or a VCS path' + ), ) packagename = serializers.CharField( required=False, allow_blank=True, label=_('Package Name'), - help_text=_('Name for the Plugin Package - can also contain a version indicator'), + help_text=_( + 'Name for the Plugin Package - can also contain a version indicator' + ), ) confirm = serializers.BooleanField( label=_('Confirm plugin installation'), - help_text=_('This will install this plugin now into the current instance. The instance will go into maintenance.') + help_text=_( + 'This will install this plugin now into the current instance. The instance will go into maintenance.' + ), ) def validate(self, data): @@ -128,6 +125,7 @@ class PluginConfigInstallSerializer(serializers.Serializer): class PluginConfigEmptySerializer(serializers.Serializer): """Serializer for a PluginConfig.""" + ... @@ -135,26 +133,32 @@ class PluginReloadSerializer(serializers.Serializer): """Serializer for remotely forcing plugin registry reload""" full_reload = serializers.BooleanField( - required=False, default=False, + required=False, + default=False, label=_("Full reload"), - help_text=_("Perform a full reload of the plugin registry") + help_text=_("Perform a full reload of the plugin registry"), ) force_reload = serializers.BooleanField( - required=False, default=False, + required=False, + default=False, label=_("Force reload"), - help_text=_("Force a reload of the plugin registry, even if it is already loaded") + help_text=_( + "Force a reload of the plugin registry, even if it is already loaded" + ), ) collect_plugins = serializers.BooleanField( - required=False, default=False, + required=False, + default=False, label=_("Collect plugins"), - help_text=_("Collect plugins and add them to the registry") + help_text=_("Collect plugins and add them to the registry"), ) def save(self): """Reload the plugin registry.""" from plugin.registry import registry + registry.reload_plugins( full_reload=self.validated_data.get('full_reload', False), force_reload=self.validated_data.get('force_reload', False), @@ -168,9 +172,10 @@ class PluginActivateSerializer(serializers.Serializer): model = PluginConfig active = serializers.BooleanField( - required=False, default=True, + required=False, + default=True, label=_('Activate Plugin'), - help_text=_('Activate this plugin') + help_text=_('Activate this plugin'), ) def update(self, instance, validated_data): @@ -191,9 +196,7 @@ class PluginSettingSerializer(GenericReferencedSettingSerializer): """Serializer for the PluginSetting model.""" MODEL = PluginSetting - EXTRA_FIELDS = [ - 'plugin', - ] + EXTRA_FIELDS = ['plugin'] plugin = serializers.CharField(source='plugin.key', read_only=True) @@ -202,7 +205,7 @@ class NotificationUserSettingSerializer(GenericReferencedSettingSerializer): """Serializer for the PluginSetting model.""" MODEL = NotificationUserSetting - EXTRA_FIELDS = ['method', ] + EXTRA_FIELDS = ['method'] method = serializers.CharField(read_only=True) diff --git a/InvenTree/plugin/templatetags/plugin_extras.py b/InvenTree/plugin/templatetags/plugin_extras.py index 02fc42f8b8..0483b1588e 100644 --- a/InvenTree/plugin/templatetags/plugin_extras.py +++ b/InvenTree/plugin/templatetags/plugin_extras.py @@ -85,11 +85,14 @@ def notification_settings_list(context, *args, **kwargs): @register.simple_tag(takes_context=True) def notification_list(context, *args, **kwargs): """List of all notification methods.""" - return [{ - 'slug': a.METHOD_NAME, - 'icon': a.METHOD_ICON, - 'setting': a.GLOBAL_SETTING, - 'plugin': a.plugin, - 'description': a.__doc__, - 'name': a.__name__ - } for a in storage.liste] + return [ + { + 'slug': a.METHOD_NAME, + 'icon': a.METHOD_ICON, + 'setting': a.GLOBAL_SETTING, + 'plugin': a.plugin, + 'description': a.__doc__, + 'name': a.__name__, + } + for a in storage.liste + ] diff --git a/InvenTree/plugin/test_api.py b/InvenTree/plugin/test_api.py index 442a493d48..bf094bde49 100644 --- a/InvenTree/plugin/test_api.py +++ b/InvenTree/plugin/test_api.py @@ -12,12 +12,7 @@ from plugin.models import PluginConfig class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): """Tests the plugin API endpoints""" - roles = [ - 'admin.add', - 'admin.view', - 'admin.change', - 'admin.delete', - ] + roles = ['admin.add', 'admin.view', 'admin.change', 'admin.delete'] def setUp(self): """Setup for all tests.""" @@ -36,31 +31,21 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): url, { 'confirm': True, - 'packagename': 'invalid_package_name-asdads-asfd-asdf-asdf-asdf' + 'packagename': 'invalid_package_name-asdads-asfd-asdf-asdf-asdf', }, - expected_code=400 + expected_code=400, ) # valid - Pypi data = self.post( - url, - { - 'confirm': True, - 'packagename': self.PKG_NAME - }, - expected_code=201, + url, {'confirm': True, 'packagename': self.PKG_NAME}, expected_code=201 ).data self.assertEqual(data['success'], 'Installed plugin successfully') # valid - github url data = self.post( - url, - { - 'confirm': True, - 'url': self.PKG_URL - }, - expected_code=201, + url, {'confirm': True, 'url': self.PKG_URL}, expected_code=201 ).data self.assertEqual(data['success'], 'Installed plugin successfully') @@ -68,11 +53,7 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): # valid - github url and package name data = self.post( url, - { - 'confirm': True, - 'url': self.PKG_URL, - 'packagename': self.PKG_NAME, - }, + {'confirm': True, 'url': self.PKG_URL, 'packagename': self.PKG_NAME}, expected_code=201, ).data self.assertEqual(data['success'], 'Installed plugin successfully') @@ -82,24 +63,23 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): self.post(url, {}, expected_code=400) # no package info - data = self.post(url, { - 'confirm': True, - }, expected_code=400).data + data = self.post(url, {'confirm': True}, expected_code=400).data self.assertEqual(data['url'][0].title().upper(), self.MSG_NO_PKG.upper()) - self.assertEqual(data['packagename'][0].title().upper(), self.MSG_NO_PKG.upper()) + self.assertEqual( + data['packagename'][0].title().upper(), self.MSG_NO_PKG.upper() + ) # not confirmed - self.post(url, { - 'packagename': self.PKG_NAME - }, expected_code=400) + self.post(url, {'packagename': self.PKG_NAME}, expected_code=400) - data = self.post(url, { - 'packagename': self.PKG_NAME, - 'confirm': False, - }, expected_code=400).data + data = self.post( + url, {'packagename': self.PKG_NAME, 'confirm': False}, expected_code=400 + ).data - self.assertEqual(data['confirm'][0].title().upper(), 'Installation not confirmed'.upper()) + self.assertEqual( + data['confirm'][0].title().upper(), 'Installation not confirmed'.upper() + ) def test_plugin_activate(self): """Test the plugin activate.""" @@ -122,7 +102,11 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): # Activate plugin with detail url assert_plugin_active(self, False) - response = self.client.patch(reverse('api-plugin-detail-activate', kwargs={'pk': test_plg.id}), {}, follow=True) + response = self.client.patch( + reverse('api-plugin-detail-activate', kwargs={'pk': test_plg.id}), + {}, + follow=True, + ) self.assertEqual(response.status_code, 200) assert_plugin_active(self, True) @@ -132,7 +116,9 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): # Activate plugin assert_plugin_active(self, False) - response = self.client.patch(reverse('api-plugin-activate'), {'pk': test_plg.pk}, follow=True) + response = self.client.patch( + reverse('api-plugin-activate'), {'pk': test_plg.pk}, follow=True + ) self.assertEqual(response.status_code, 200) assert_plugin_active(self, True) @@ -142,33 +128,47 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): test_plg = self.plugin_confs.first() # deactivate plugin - response = self.client.post(url, { - 'action': 'plugin_deactivate', - 'index': 0, - '_selected_action': [test_plg.pk], - }, follow=True) + response = self.client.post( + url, + { + 'action': 'plugin_deactivate', + 'index': 0, + '_selected_action': [test_plg.pk], + }, + follow=True, + ) self.assertEqual(response.status_code, 200) # deactivate plugin - deactivate again -> nothing will happen but the nothing 'changed' function is triggered - response = self.client.post(url, { - 'action': 'plugin_deactivate', - 'index': 0, - '_selected_action': [test_plg.pk], - }, follow=True) + response = self.client.post( + url, + { + 'action': 'plugin_deactivate', + 'index': 0, + '_selected_action': [test_plg.pk], + }, + follow=True, + ) self.assertEqual(response.status_code, 200) # activate plugin - response = self.client.post(url, { - 'action': 'plugin_activate', - 'index': 0, - '_selected_action': [test_plg.pk], - }, follow=True) + response = self.client.post( + url, + { + 'action': 'plugin_activate', + 'index': 0, + '_selected_action': [test_plg.pk], + }, + follow=True, + ) self.assertEqual(response.status_code, 200) # save to deactivate a plugin - response = self.client.post(reverse('admin:plugin_pluginconfig_change', args=(test_plg.pk, )), { - '_save': 'Save', - }, follow=True) + response = self.client.post( + reverse('admin:plugin_pluginconfig_change', args=(test_plg.pk,)), + {'_save': 'Save'}, + follow=True, + ) self.assertEqual(response.status_code, 200) def test_model(self): @@ -177,7 +177,9 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): plg = self.plugin_confs.first() mixin_dict = plg.mixins() self.assertIn('base', mixin_dict) - self.assertDictContainsSubset({'base': {'key': 'base', 'human_name': 'base'}}, mixin_dict) + self.assertDictContainsSubset( + {'base': {'key': 'base', 'human_name': 'base'}}, mixin_dict + ) # check reload on save with self.assertWarns(Warning) as cm: @@ -215,62 +217,56 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): self.client.patch(url, {}, expected_code=200) # Valid plugin settings endpoints - valid_settings = [ - 'SELECT_PART', - 'API_KEY', - 'NUMERICAL_SETTING', - ] + valid_settings = ['SELECT_PART', 'API_KEY', 'NUMERICAL_SETTING'] for key in valid_settings: response = self.get( - reverse('api-plugin-setting-detail', kwargs={ - 'plugin': 'sample', - 'key': key - })) + reverse( + 'api-plugin-setting-detail', kwargs={'plugin': 'sample', 'key': key} + ) + ) self.assertEqual(response.data['key'], key) # Test that an invalid setting key raises a 404 error response = self.get( - reverse('api-plugin-setting-detail', kwargs={ - 'plugin': 'sample', - 'key': 'INVALID_SETTING' - }), - expected_code=404 + reverse( + 'api-plugin-setting-detail', + kwargs={'plugin': 'sample', 'key': 'INVALID_SETTING'}, + ), + expected_code=404, ) # Test that a protected setting returns hidden value response = self.get( - reverse('api-plugin-setting-detail', kwargs={ - 'plugin': 'sample', - 'key': 'PROTECTED_SETTING' - }), - expected_code=200 + reverse( + 'api-plugin-setting-detail', + kwargs={'plugin': 'sample', 'key': 'PROTECTED_SETTING'}, + ), + expected_code=200, ) self.assertEqual(response.data['value'], '***') # Test that we can update a setting value response = self.patch( - reverse('api-plugin-setting-detail', kwargs={ - 'plugin': 'sample', - 'key': 'NUMERICAL_SETTING' - }), - { - 'value': 456 - }, - expected_code=200 + reverse( + 'api-plugin-setting-detail', + kwargs={'plugin': 'sample', 'key': 'NUMERICAL_SETTING'}, + ), + {'value': 456}, + expected_code=200, ) self.assertEqual(response.data['value'], '456') # Retrieve the value again response = self.get( - reverse('api-plugin-setting-detail', kwargs={ - 'plugin': 'sample', - 'key': 'NUMERICAL_SETTING' - }), - expected_code=200 + reverse( + 'api-plugin-setting-detail', + kwargs={'plugin': 'sample', 'key': 'NUMERICAL_SETTING'}, + ), + expected_code=200, ) self.assertEqual(response.data['value'], '456') diff --git a/InvenTree/plugin/test_helpers.py b/InvenTree/plugin/test_helpers.py index 53b2622592..4a32c02fb9 100644 --- a/InvenTree/plugin/test_helpers.py +++ b/InvenTree/plugin/test_helpers.py @@ -10,6 +10,7 @@ class HelperTests(TestCase): def test_render_template(self): """Check if render_template helper works.""" + class ErrorSource: slug = 'sampleplg' @@ -18,6 +19,8 @@ class HelperTests(TestCase): self.assertEqual(response, '

    123

    \n') # Wrong sample - response = render_template(ErrorSource(), 'sample/wrongsample.html', {'abc': 123}) + response = render_template( + ErrorSource(), 'sample/wrongsample.html', {'abc': 123} + ) self.assertTrue('lert alert-block alert-danger' in response) self.assertTrue('Template file sample/wrongsample.html' in response) diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py index b0b91ed2bf..9dc6d7f63f 100644 --- a/InvenTree/plugin/test_plugin.py +++ b/InvenTree/plugin/test_plugin.py @@ -12,8 +12,10 @@ from django.test import TestCase, override_settings import plugin.templatetags.plugin_extras as plugin_tags from plugin import InvenTreePlugin, registry -from plugin.samples.integration.another_sample import (NoIntegrationPlugin, - WrongIntegrationPlugin) +from plugin.samples.integration.another_sample import ( + NoIntegrationPlugin, + WrongIntegrationPlugin, +) from plugin.samples.integration.sample import SampleIntegrationPlugin @@ -38,7 +40,7 @@ class PluginTagTests(TestCase): """Check all plugins are listed.""" self.assertEqual( plugin_tags.plugin_settings(self.sample), - registry.mixins_settings.get(self.sample) + registry.mixins_settings.get(self.sample), ) def test_tag_mixin_enabled(self): @@ -54,7 +56,9 @@ class PluginTagTests(TestCase): def test_tag_safe_url(self): """Test that the safe url tag works expected.""" # right url - self.assertEqual(plugin_tags.safe_url('api-plugin-install'), '/api/plugins/install/') + self.assertEqual( + plugin_tags.safe_url('api-plugin-install'), '/api/plugins/install/' + ) # wrong url self.assertEqual(plugin_tags.safe_url('indexas'), None) @@ -75,6 +79,7 @@ class InvenTreePluginTests(TestCase): class NamedPlugin(InvenTreePlugin): """a named plugin.""" + NAME = 'abc123' cls.named_plugin = NamedPlugin() @@ -179,7 +184,9 @@ class InvenTreePluginTests(TestCase): with self.assertWarns(DeprecationWarning): self.assertEqual(self.plugin_old.slug, 'old') # check default value is used - self.assertEqual(self.plugin_old.get_meta_value('ABC', 'ABCD', '123'), '123') + self.assertEqual( + self.plugin_old.get_meta_value('ABC', 'ABCD', '123'), '123' + ) def test_version(self): """Test Version checks""" @@ -270,7 +277,12 @@ class RegistryTests(TestCase): self.assertEqual(len(registry.errors), 3) # There should be at least one discovery error in the module `broken_file` self.assertTrue(len(registry.errors.get('discovery')) > 0) - self.assertEqual(registry.errors.get('discovery')[0]['broken_file'], "name 'bb' is not defined") + self.assertEqual( + registry.errors.get('discovery')[0]['broken_file'], + "name 'bb' is not defined", + ) # There should be at least one load error with an intentional KeyError self.assertTrue(len(registry.errors.get('load')) > 0) - self.assertEqual(registry.errors.get('load')[0]['broken_sample'], "'This is a dummy error'") + self.assertEqual( + registry.errors.get('load')[0]['broken_sample'], "'This is a dummy error'" + ) diff --git a/InvenTree/plugin/urls.py b/InvenTree/plugin/urls.py index d337dee9ee..dc25226171 100644 --- a/InvenTree/plugin/urls.py +++ b/InvenTree/plugin/urls.py @@ -13,7 +13,9 @@ def get_plugin_urls(): urls = [] # Only allow custom routing if the setting is enabled - if InvenTreeSetting.get_setting('ENABLE_PLUGINS_URL', False, create=False, cache=False): + if InvenTreeSetting.get_setting( + 'ENABLE_PLUGINS_URL', False, create=False, cache=False + ): for plugin in registry.plugins.values(): if plugin.mixin_enabled('urls'): urls.append(plugin.urlpatterns) diff --git a/InvenTree/plugin/views.py b/InvenTree/plugin/views.py index 49a772f401..a332046f3e 100644 --- a/InvenTree/plugin/views.py +++ b/InvenTree/plugin/views.py @@ -21,13 +21,16 @@ class InvenTreePluginViewMixin: panels = [] for plug in registry.with_mixin('panel', active=True): - try: panels += plug.render_panels(self, self.request, ctx) except Exception: # Log the error to the database log_error(self.request.path) - logger.exception("Plugin '%s' could not render custom panels at '%s'", plug.slug, self.request.path) + logger.exception( + "Plugin '%s' could not render custom panels at '%s'", + plug.slug, + self.request.path, + ) return panels diff --git a/InvenTree/report/admin.py b/InvenTree/report/admin.py index 18b60392c5..fa359eee70 100644 --- a/InvenTree/report/admin.py +++ b/InvenTree/report/admin.py @@ -2,23 +2,34 @@ from django.contrib import admin -from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport, - ReportAsset, ReportSnippet, ReturnOrderReport, - SalesOrderReport, StockLocationReport, TestReport) +from .models import ( + BillOfMaterialsReport, + BuildReport, + PurchaseOrderReport, + ReportAsset, + ReportSnippet, + ReturnOrderReport, + SalesOrderReport, + StockLocationReport, + TestReport, +) class ReportTemplateAdmin(admin.ModelAdmin): """Admin class for the various reporting models""" + list_display = ('name', 'description', 'template', 'filters', 'enabled', 'revision') class ReportSnippetAdmin(admin.ModelAdmin): """Admin class for the ReportSnippet model""" + list_display = ('id', 'snippet', 'description') class ReportAssetAdmin(admin.ModelAdmin): """Admin class for the ReportAsset model""" + list_display = ('id', 'asset', 'description') diff --git a/InvenTree/report/api.py b/InvenTree/report/api.py index 876f0fff1b..83e983fe96 100644 --- a/InvenTree/report/api.py +++ b/InvenTree/report/api.py @@ -20,36 +20,37 @@ import part.models from InvenTree.api import MetadataView from InvenTree.exceptions import log_error from InvenTree.filters import InvenTreeSearchFilter -from InvenTree.mixins import (ListCreateAPI, RetrieveAPI, - RetrieveUpdateDestroyAPI) +from InvenTree.mixins import ListCreateAPI, RetrieveAPI, RetrieveUpdateDestroyAPI from stock.models import StockItem, StockItemAttachment, StockLocation -from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport, - ReturnOrderReport, SalesOrderReport, StockLocationReport, - TestReport) -from .serializers import (BOMReportSerializer, BuildReportSerializer, - PurchaseOrderReportSerializer, - ReturnOrderReportSerializer, - SalesOrderReportSerializer, - StockLocationReportSerializer, TestReportSerializer) +from .models import ( + BillOfMaterialsReport, + BuildReport, + PurchaseOrderReport, + ReturnOrderReport, + SalesOrderReport, + StockLocationReport, + TestReport, +) +from .serializers import ( + BOMReportSerializer, + BuildReportSerializer, + PurchaseOrderReportSerializer, + ReturnOrderReportSerializer, + SalesOrderReportSerializer, + StockLocationReportSerializer, + TestReportSerializer, +) class ReportListView(ListCreateAPI): """Generic API class for report templates.""" - filter_backends = [ - DjangoFilterBackend, - InvenTreeSearchFilter, - ] + filter_backends = [DjangoFilterBackend, InvenTreeSearchFilter] - filterset_fields = [ - 'enabled', - ] + filterset_fields = ['enabled'] - search_fields = [ - 'name', - 'description', - ] + search_fields = ['name', 'description'] class ReportFilterMixin: @@ -71,7 +72,9 @@ class ReportFilterMixin: def get_items(self): """Return a list of database objects from query parameters""" if not self.ITEM_MODEL: - raise NotImplementedError(f"ITEM_MODEL attribute not defined for {__class__}") + raise NotImplementedError( + f"ITEM_MODEL attribute not defined for {__class__}" + ) ids = [] @@ -169,16 +172,16 @@ class ReportPrintMixin: """Print this report template against a number of pre-validated items.""" if len(items_to_print) == 0: # No valid items provided, return an error message - data = { - 'error': _('No valid objects provided to template'), - } + data = {'error': _('No valid objects provided to template')} return Response(data, status=400) outputs = [] # In debug mode, generate single HTML output, rather than PDF - debug_mode = common.models.InvenTreeSetting.get_setting('REPORT_DEBUG_MODE', cache=False) + debug_mode = common.models.InvenTreeSetting.get_setting( + 'REPORT_DEBUG_MODE', cache=False + ) # Start with a default report name report_name = "report.pdf" @@ -207,7 +210,9 @@ class ReportPrintMixin: return Response( { - 'error': _(f"Template file '{template}' is missing or does not exist"), + 'error': _( + f"Template file '{template}' is missing or does not exist" + ) }, status=400, ) @@ -235,7 +240,6 @@ class ReportPrintMixin: pdf = outputs[0].get_document().copy(pages).write_pdf() except TemplateDoesNotExist as e: - template = str(e) if not template: @@ -243,18 +247,19 @@ class ReportPrintMixin: return Response( { - 'error': _(f"Template file '{template}' is missing or does not exist"), + 'error': _( + f"Template file '{template}' is missing or does not exist" + ) }, status=400, ) - inline = common.models.InvenTreeUserSetting.get_setting('REPORT_INLINE', user=request.user, cache=False) + inline = common.models.InvenTreeUserSetting.get_setting( + 'REPORT_INLINE', user=request.user, cache=False + ) return InvenTree.helpers.DownloadFile( - pdf, - report_name, - content_type='application/pdf', - inline=inline, + pdf, report_name, content_type='application/pdf', inline=inline ) except Exception as exc: @@ -294,11 +299,13 @@ class StockItemTestReportList(StockItemTestReportMixin, ReportListView): - enabled: Filter by enabled / disabled status - item: Filter by stock item(s) """ + pass class StockItemTestReportDetail(StockItemTestReportMixin, RetrieveUpdateDestroyAPI): """API endpoint for a single TestReport object.""" + pass @@ -307,8 +314,9 @@ class StockItemTestReportPrint(StockItemTestReportMixin, ReportPrintMixin, Retri def report_callback(self, item, report, request): """Callback to (optionally) save a copy of the generated report""" - if common.models.InvenTreeSetting.get_setting('REPORT_ATTACH_TEST_REPORT', cache=False): - + if common.models.InvenTreeSetting.get_setting( + 'REPORT_ATTACH_TEST_REPORT', cache=False + ): # Construct a PDF file object try: pdf = report.get_document().write_pdf() @@ -320,7 +328,7 @@ class StockItemTestReportPrint(StockItemTestReportMixin, ReportPrintMixin, Retri attachment=pdf_content, stock_item=item, user=request.user, - comment=_("Test report") + comment=_("Test report"), ) @@ -342,16 +350,19 @@ class BOMReportList(BOMReportMixin, ReportListView): - enabled: Filter by enabled / disabled status - part: Filter by part(s) """ + pass class BOMReportDetail(BOMReportMixin, RetrieveUpdateDestroyAPI): """API endpoint for a single BillOfMaterialReport object.""" + pass class BOMReportPrint(BOMReportMixin, ReportPrintMixin, RetrieveAPI): """API endpoint for printing a BillOfMaterialReport object.""" + pass @@ -373,16 +384,19 @@ class BuildReportList(BuildReportMixin, ReportListView): - enabled: Filter by enabled / disabled status - build: Filter by Build object """ + pass class BuildReportDetail(BuildReportMixin, RetrieveUpdateDestroyAPI): """API endpoint for a single BuildReport object.""" + pass class BuildReportPrint(BuildReportMixin, ReportPrintMixin, RetrieveAPI): """API endpoint for printing a BuildReport.""" + pass @@ -398,16 +412,19 @@ class PurchaseOrderReportMixin(ReportFilterMixin): class PurchaseOrderReportList(PurchaseOrderReportMixin, ReportListView): """API list endpoint for the PurchaseOrderReport model""" + pass class PurchaseOrderReportDetail(PurchaseOrderReportMixin, RetrieveUpdateDestroyAPI): """API endpoint for a single PurchaseOrderReport object.""" + pass class PurchaseOrderReportPrint(PurchaseOrderReportMixin, ReportPrintMixin, RetrieveAPI): """API endpoint for printing a PurchaseOrderReport object.""" + pass @@ -423,16 +440,19 @@ class SalesOrderReportMixin(ReportFilterMixin): class SalesOrderReportList(SalesOrderReportMixin, ReportListView): """API list endpoint for the SalesOrderReport model""" + pass class SalesOrderReportDetail(SalesOrderReportMixin, RetrieveUpdateDestroyAPI): """API endpoint for a single SalesOrderReport object.""" + pass class SalesOrderReportPrint(SalesOrderReportMixin, ReportPrintMixin, RetrieveAPI): """API endpoint for printing a PurchaseOrderReport object.""" + pass @@ -448,16 +468,19 @@ class ReturnOrderReportMixin(ReportFilterMixin): class ReturnOrderReportList(ReturnOrderReportMixin, ReportListView): """API list endpoint for the ReturnOrderReport model""" + pass class ReturnOrderReportDetail(ReturnOrderReportMixin, RetrieveUpdateDestroyAPI): """API endpoint for a single ReturnOrderReport object""" + pass class ReturnOrderReportPrint(ReturnOrderReportMixin, ReportPrintMixin, RetrieveAPI): """API endpoint for printing a ReturnOrderReport object""" + pass @@ -472,107 +495,236 @@ class StockLocationReportMixin(ReportFilterMixin): class StockLocationReportList(StockLocationReportMixin, ReportListView): """API list endpoint for the StockLocationReportList model""" + pass class StockLocationReportDetail(StockLocationReportMixin, RetrieveUpdateDestroyAPI): """API endpoint for a single StockLocationReportDetail object.""" + pass class StockLocationReportPrint(StockLocationReportMixin, ReportPrintMixin, RetrieveAPI): """API endpoint for printing a StockLocationReportPrint object""" + pass report_api_urls = [ - # Purchase order reports - re_path(r'po/', include([ - # Detail views - path(r'/', include([ - re_path(r'print/', PurchaseOrderReportPrint.as_view(), name='api-po-report-print'), - re_path(r'metadata/', MetadataView.as_view(), {'model': PurchaseOrderReport}, name='api-po-report-metadata'), - path('', PurchaseOrderReportDetail.as_view(), name='api-po-report-detail'), - ])), - - # List view - path('', PurchaseOrderReportList.as_view(), name='api-po-report-list'), - ])), - + re_path( + r'po/', + include([ + # Detail views + path( + r'/', + include([ + re_path( + r'print/', + PurchaseOrderReportPrint.as_view(), + name='api-po-report-print', + ), + re_path( + r'metadata/', + MetadataView.as_view(), + {'model': PurchaseOrderReport}, + name='api-po-report-metadata', + ), + path( + '', + PurchaseOrderReportDetail.as_view(), + name='api-po-report-detail', + ), + ]), + ), + # List view + path('', PurchaseOrderReportList.as_view(), name='api-po-report-list'), + ]), + ), # Sales order reports - re_path(r'so/', include([ - # Detail views - path(r'/', include([ - re_path(r'print/', SalesOrderReportPrint.as_view(), name='api-so-report-print'), - re_path(r'metadata/', MetadataView.as_view(), {'model': SalesOrderReport}, name='api-so-report-metadata'), - path('', SalesOrderReportDetail.as_view(), name='api-so-report-detail'), - ])), - - path('', SalesOrderReportList.as_view(), name='api-so-report-list'), - ])), - + re_path( + r'so/', + include([ + # Detail views + path( + r'/', + include([ + re_path( + r'print/', + SalesOrderReportPrint.as_view(), + name='api-so-report-print', + ), + re_path( + r'metadata/', + MetadataView.as_view(), + {'model': SalesOrderReport}, + name='api-so-report-metadata', + ), + path( + '', + SalesOrderReportDetail.as_view(), + name='api-so-report-detail', + ), + ]), + ), + path('', SalesOrderReportList.as_view(), name='api-so-report-list'), + ]), + ), # Return order reports - re_path(r'ro/', include([ - path(r'/', include([ - path(r'print/', ReturnOrderReportPrint.as_view(), name='api-return-order-report-print'), - re_path(r'metadata/', MetadataView.as_view(), {'model': ReturnOrderReport}, name='api-so-report-metadata'), - path('', ReturnOrderReportDetail.as_view(), name='api-return-order-report-detail'), - ])), - path('', ReturnOrderReportList.as_view(), name='api-return-order-report-list'), - ])), - + re_path( + r'ro/', + include([ + path( + r'/', + include([ + path( + r'print/', + ReturnOrderReportPrint.as_view(), + name='api-return-order-report-print', + ), + re_path( + r'metadata/', + MetadataView.as_view(), + {'model': ReturnOrderReport}, + name='api-so-report-metadata', + ), + path( + '', + ReturnOrderReportDetail.as_view(), + name='api-return-order-report-detail', + ), + ]), + ), + path( + '', ReturnOrderReportList.as_view(), name='api-return-order-report-list' + ), + ]), + ), # Build reports - re_path(r'build/', include([ - # Detail views - path(r'/', include([ - re_path(r'print/?', BuildReportPrint.as_view(), name='api-build-report-print'), - re_path(r'metadata/', MetadataView.as_view(), {'model': BuildReport}, name='api-build-report-metadata'), - re_path(r'^.$', BuildReportDetail.as_view(), name='api-build-report-detail'), - ])), - - # List view - re_path(r'^.*$', BuildReportList.as_view(), name='api-build-report-list'), - ])), - + re_path( + r'build/', + include([ + # Detail views + path( + r'/', + include([ + re_path( + r'print/?', + BuildReportPrint.as_view(), + name='api-build-report-print', + ), + re_path( + r'metadata/', + MetadataView.as_view(), + {'model': BuildReport}, + name='api-build-report-metadata', + ), + re_path( + r'^.$', + BuildReportDetail.as_view(), + name='api-build-report-detail', + ), + ]), + ), + # List view + re_path(r'^.*$', BuildReportList.as_view(), name='api-build-report-list'), + ]), + ), # Bill of Material reports - re_path(r'bom/', include([ - - # Detail views - path(r'/', include([ - re_path(r'print/?', BOMReportPrint.as_view(), name='api-bom-report-print'), - re_path(r'metadata/', MetadataView.as_view(), {'model': BillOfMaterialsReport}, name='api-bom-report-metadata'), - re_path(r'^.*$', BOMReportDetail.as_view(), name='api-bom-report-detail'), - ])), - - # List view - re_path(r'^.*$', BOMReportList.as_view(), name='api-bom-report-list'), - ])), - + re_path( + r'bom/', + include([ + # Detail views + path( + r'/', + include([ + re_path( + r'print/?', + BOMReportPrint.as_view(), + name='api-bom-report-print', + ), + re_path( + r'metadata/', + MetadataView.as_view(), + {'model': BillOfMaterialsReport}, + name='api-bom-report-metadata', + ), + re_path( + r'^.*$', BOMReportDetail.as_view(), name='api-bom-report-detail' + ), + ]), + ), + # List view + re_path(r'^.*$', BOMReportList.as_view(), name='api-bom-report-list'), + ]), + ), # Stock item test reports - re_path(r'test/', include([ - # Detail views - path(r'/', include([ - re_path(r'print/?', StockItemTestReportPrint.as_view(), name='api-stockitem-testreport-print'), - re_path(r'metadata/', MetadataView.as_view(), {'report': TestReport}, name='api-stockitem-testreport-metadata'), - re_path(r'^.*$', StockItemTestReportDetail.as_view(), name='api-stockitem-testreport-detail'), - ])), - - # List view - re_path(r'^.*$', StockItemTestReportList.as_view(), name='api-stockitem-testreport-list'), - ])), - + re_path( + r'test/', + include([ + # Detail views + path( + r'/', + include([ + re_path( + r'print/?', + StockItemTestReportPrint.as_view(), + name='api-stockitem-testreport-print', + ), + re_path( + r'metadata/', + MetadataView.as_view(), + {'report': TestReport}, + name='api-stockitem-testreport-metadata', + ), + re_path( + r'^.*$', + StockItemTestReportDetail.as_view(), + name='api-stockitem-testreport-detail', + ), + ]), + ), + # List view + re_path( + r'^.*$', + StockItemTestReportList.as_view(), + name='api-stockitem-testreport-list', + ), + ]), + ), # Stock Location reports (Stock Location Reports -> sir) - re_path(r'slr/', include([ - # Detail views - path(r'/', include([ - re_path(r'print/?', StockLocationReportPrint.as_view(), name='api-stocklocation-report-print'), - re_path(r'metadata/', MetadataView.as_view(), {'report': StockLocationReport}, name='api-stocklocation-report-metadata'), - re_path(r'^.*$', StockLocationReportDetail.as_view(), name='api-stocklocation-report-detail'), - ])), - - # List view - re_path(r'^.*$', StockLocationReportList.as_view(), name='api-stocklocation-report-list'), - ])), - + re_path( + r'slr/', + include([ + # Detail views + path( + r'/', + include([ + re_path( + r'print/?', + StockLocationReportPrint.as_view(), + name='api-stocklocation-report-print', + ), + re_path( + r'metadata/', + MetadataView.as_view(), + {'report': StockLocationReport}, + name='api-stocklocation-report-metadata', + ), + re_path( + r'^.*$', + StockLocationReportDetail.as_view(), + name='api-stocklocation-report-detail', + ), + ]), + ), + # List view + re_path( + r'^.*$', + StockLocationReportList.as_view(), + name='api-stocklocation-report-list', + ), + ]), + ), ] diff --git a/InvenTree/report/apps.py b/InvenTree/report/apps.py index 7f89348a2a..4362f5f4f4 100644 --- a/InvenTree/report/apps.py +++ b/InvenTree/report/apps.py @@ -16,12 +16,17 @@ logger = logging.getLogger("inventree") class ReportConfig(AppConfig): """Configuration class for the 'report' app""" + name = 'report' def ready(self): """This function is called whenever the report app is loaded.""" - from InvenTree.ready import (canAppAccessDatabase, isImportingData, - isInMainThread, isPluginRegistryLoaded) + from InvenTree.ready import ( + canAppAccessDatabase, + isImportingData, + isInMainThread, + isPluginRegistryLoaded, + ) # skip loading if plugin registry is not loaded or we run in a background thread if not isPluginRegistryLoaded() or not isInMainThread(): @@ -33,7 +38,6 @@ class ReportConfig(AppConfig): # Create entries for default report templates if canAppAccessDatabase(allow_test=False) and not isImportingData(): - try: self.create_default_test_reports() self.create_default_build_reports() @@ -42,24 +46,24 @@ class ReportConfig(AppConfig): self.create_default_sales_order_reports() self.create_default_return_order_reports() self.create_default_stock_location_reports() - except (AppRegistryNotReady, IntegrityError, OperationalError, ProgrammingError): + except ( + AppRegistryNotReady, + IntegrityError, + OperationalError, + ProgrammingError, + ): # Database might not yet be ready - warnings.warn('Database was not ready for creating reports', stacklevel=2) + warnings.warn( + 'Database was not ready for creating reports', stacklevel=2 + ) def create_default_reports(self, model, reports): """Copy default report files across to the media directory.""" # Source directory for report templates - src_dir = Path(__file__).parent.joinpath( - 'templates', - 'report', - ) + src_dir = Path(__file__).parent.joinpath('templates', 'report') # Destination directory - dst_dir = settings.MEDIA_ROOT.joinpath( - 'report', - 'inventree', - model.getSubdir(), - ) + dst_dir = settings.MEDIA_ROOT.joinpath('report', 'inventree', model.getSubdir()) if not dst_dir.exists(): logger.info("Creating missing directory: '%s'", dst_dir) @@ -67,13 +71,9 @@ class ReportConfig(AppConfig): # Copy each report template across (if required) for report in reports: - # Destination filename filename = os.path.join( - 'report', - 'inventree', - model.getSubdir(), - report['file'], + 'report', 'inventree', model.getSubdir(), report['file'] ) src_file = src_dir.joinpath(report['file']) @@ -94,7 +94,7 @@ class ReportConfig(AppConfig): name=report['name'], description=report['description'], template=filename, - enabled=True + enabled=True, ) except Exception: @@ -114,7 +114,7 @@ class ReportConfig(AppConfig): 'file': 'inventree_test_report.html', 'name': 'InvenTree Test Report', 'description': 'Stock item test report', - }, + } ] self.create_default_reports(TestReport, reports) diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index 42085825eb..f301ece04d 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -105,7 +105,9 @@ class ReportBase(models.Model): """Initialize the particular report instance""" super().__init__(*args, **kwargs) - self._meta.get_field('page_size').choices = report.helpers.report_page_size_options() + self._meta.get_field( + 'page_size' + ).choices = report.helpers.report_page_size_options() def save(self, *args, **kwargs): """Perform additional actions when the report is saved""" @@ -134,7 +136,6 @@ class ReportBase(models.Model): # If the report file is the *same* filename as the one being uploaded, # remove the original one from the media directory if str(filename) == str(self.template): - if fullpath.exists(): logger.info("Deleting existing report template: '%s'", filename) os.remove(fullpath) @@ -166,7 +167,8 @@ class ReportBase(models.Model): return template name = models.CharField( - blank=False, max_length=100, + blank=False, + max_length=100, verbose_name=_('Name'), help_text=_('Template name'), ) @@ -181,7 +183,7 @@ class ReportBase(models.Model): description = models.CharField( max_length=250, verbose_name=_('Description'), - help_text=_("Report template description") + help_text=_("Report template description"), ) revision = models.PositiveIntegerField( @@ -213,6 +215,7 @@ class ReportTemplateBase(MetadataMixin, ReportBase): class Meta: """Metaclass options. Abstract ensures no database table is created.""" + abstract = True # Pass a single top-level object to the report template @@ -225,7 +228,9 @@ class ReportTemplateBase(MetadataMixin, ReportBase): def get_report_size(self): """Return the printable page size for this report""" try: - page_size_default = common.models.InvenTreeSetting.get_setting('REPORT_DEFAULT_PAGE_SIZE', 'A4') + page_size_default = common.models.InvenTreeSetting.get_setting( + 'REPORT_DEFAULT_PAGE_SIZE', 'A4' + ) except Exception: page_size_default = 'A4' @@ -293,11 +298,10 @@ class ReportTemplateBase(MetadataMixin, ReportBase): base_url=request.build_absolute_uri("/"), presentational_hints=True, filename=self.generate_filename(request), - **kwargs) + **kwargs, + ) - return wp.render_to_response( - self.context(request), - **kwargs) + return wp.render_to_response(self.context(request), **kwargs) filename_pattern = models.CharField( default="report.pdf", @@ -330,16 +334,18 @@ class TestReport(ReportTemplateBase): blank=True, max_length=250, verbose_name=_('Filters'), - help_text=_("StockItem query filters (comma-separated list of key=value pairs)"), - validators=[ - validate_stock_item_report_filters - ] + help_text=_( + "StockItem query filters (comma-separated list of key=value pairs)" + ), + validators=[validate_stock_item_report_filters], ) include_installed = models.BooleanField( default=False, verbose_name=_('Include Installed Tests'), - help_text=_('Include test results for stock items installed inside assembled item') + help_text=_( + 'Include test results for stock items installed inside assembled item' + ), ) def get_test_keys(self, stock_item): @@ -359,7 +365,9 @@ class TestReport(ReportTemplateBase): if test.key not in keys: keys.append(test.key) - for result in stock_item.testResultList(include_installed=self.include_installed): + for result in stock_item.testResultList( + include_installed=self.include_installed + ): if result.key not in keys: keys.append(result.key) @@ -377,8 +385,12 @@ class TestReport(ReportTemplateBase): 'test_keys': self.get_test_keys(stock_item), 'test_template_list': stock_item.part.getTestTemplates(), 'test_template_map': stock_item.part.getTestTemplateMap(), - 'results': stock_item.testResultMap(include_installed=self.include_installed), - 'result_list': stock_item.testResultList(include_installed=self.include_installed), + 'results': stock_item.testResultMap( + include_installed=self.include_installed + ), + 'result_list': stock_item.testResultList( + include_installed=self.include_installed + ), 'installed_items': stock_item.get_installed_items(cascade=True), } @@ -401,9 +413,7 @@ class BuildReport(ReportTemplateBase): max_length=250, verbose_name=_('Build Filters'), help_text=_('Build query filters (comma-separated list of key=value pairs'), - validators=[ - validate_build_report_filters, - ] + validators=[validate_build_report_filters], ) def get_context_data(self, request): @@ -443,9 +453,7 @@ class BillOfMaterialsReport(ReportTemplateBase): max_length=250, verbose_name=_('Part Filters'), help_text=_('Part query filters (comma-separated list of key=value pairs'), - validators=[ - validate_part_report_filters - ] + validators=[validate_part_report_filters], ) def get_context_data(self, request): @@ -477,9 +485,7 @@ class PurchaseOrderReport(ReportTemplateBase): max_length=250, verbose_name=_('Filters'), help_text=_('Purchase order query filters'), - validators=[ - validate_purchase_order_filters, - ] + validators=[validate_purchase_order_filters], ) def get_context_data(self, request): @@ -515,9 +521,7 @@ class SalesOrderReport(ReportTemplateBase): max_length=250, verbose_name=_('Filters'), help_text=_('Sales order query filters'), - validators=[ - validate_sales_order_filters - ] + validators=[validate_sales_order_filters], ) def get_context_data(self, request): @@ -553,9 +557,7 @@ class ReturnOrderReport(ReportTemplateBase): max_length=250, verbose_name=_('Filters'), help_text=_('Return order query filters'), - validators=[ - validate_return_order_filters, - ] + validators=[validate_return_order_filters], ) def get_context_data(self, request): @@ -584,7 +586,6 @@ def rename_snippet(instance, filename): # If the snippet file is the *same* filename as the one being uploaded, # delete the original one from the media directory if str(filename) == str(instance.snippet): - if fullpath.exists(): logger.info("Deleting existing snippet file: '%s'", filename) os.remove(fullpath) @@ -608,7 +609,11 @@ class ReportSnippet(models.Model): validators=[FileExtensionValidator(allowed_extensions=['html', 'htm'])], ) - description = models.CharField(max_length=250, verbose_name=_('Description'), help_text=_("Snippet file description")) + description = models.CharField( + max_length=250, + verbose_name=_('Description'), + help_text=_("Snippet file description"), + ) def rename_asset(instance, filename): @@ -652,7 +657,7 @@ class ReportAsset(models.Model): description = models.CharField( max_length=250, verbose_name=_('Description'), - help_text=_("Asset file description") + help_text=_("Asset file description"), ) @@ -673,10 +678,10 @@ class StockLocationReport(ReportTemplateBase): blank=True, max_length=250, verbose_name=_('Filters'), - help_text=_("stock location query filters (comma-separated list of key=value pairs)"), - validators=[ - validate_stock_location_report_filters - ] + help_text=_( + "stock location query filters (comma-separated list of key=value pairs)" + ), + validators=[validate_stock_location_report_filters], ) def get_context_data(self, request): @@ -684,7 +689,10 @@ class StockLocationReport(ReportTemplateBase): stock_location = self.object_to_print if not isinstance(stock_location, stock.models.StockLocation): - raise TypeError('Provided model is not a StockLocation object -> ' + str(type(stock_location))) + raise TypeError( + 'Provided model is not a StockLocation object -> ' + + str(type(stock_location)) + ) return { 'stock_location': stock_location, diff --git a/InvenTree/report/serializers.py b/InvenTree/report/serializers.py index 2e690b6791..5dcd9b49a7 100644 --- a/InvenTree/report/serializers.py +++ b/InvenTree/report/serializers.py @@ -1,11 +1,19 @@ """API serializers for the reporting models""" -from InvenTree.serializers import (InvenTreeAttachmentSerializerField, - InvenTreeModelSerializer) +from InvenTree.serializers import ( + InvenTreeAttachmentSerializerField, + InvenTreeModelSerializer, +) -from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport, - ReturnOrderReport, SalesOrderReport, StockLocationReport, - TestReport) +from .models import ( + BillOfMaterialsReport, + BuildReport, + PurchaseOrderReport, + ReturnOrderReport, + SalesOrderReport, + StockLocationReport, + TestReport, +) class ReportSerializerBase(InvenTreeModelSerializer): @@ -16,14 +24,7 @@ class ReportSerializerBase(InvenTreeModelSerializer): @staticmethod def report_fields(): """Generic serializer fields for a report template""" - return [ - 'pk', - 'name', - 'description', - 'template', - 'filters', - 'enabled', - ] + return ['pk', 'name', 'description', 'template', 'filters', 'enabled'] class TestReportSerializer(ReportSerializerBase): diff --git a/InvenTree/report/templatetags/barcode.py b/InvenTree/report/templatetags/barcode.py index 723064f984..268f00e504 100644 --- a/InvenTree/report/templatetags/barcode.py +++ b/InvenTree/report/templatetags/barcode.py @@ -35,11 +35,7 @@ def qrcode(data, **kwargs): """ # Construct "default" values - params = { - "box_size": 20, - "border": 1, - "version": 1, - } + params = {"box_size": 20, "border": 1, "version": 1} fill_color = kwargs.pop('fill_color', 'black') back_color = kwargs.pop('back_color', 'white') @@ -53,10 +49,7 @@ def qrcode(data, **kwargs): qr.add_data(data, optimize=20) qr.make(fit=True) - qri = qr.make_image( - fill_color=fill_color, - back_color=back_color - ) + qri = qr.make_image(fill_color=fill_color, back_color=back_color) # Render to byte-encoded image return image_data(qri, fmt=format) diff --git a/InvenTree/report/templatetags/report.py b/InvenTree/report/templatetags/report.py index 726703312d..de1cb0544d 100644 --- a/InvenTree/report/templatetags/report.py +++ b/InvenTree/report/templatetags/report.py @@ -100,7 +100,13 @@ def asset(filename): @register.simple_tag() -def uploaded_image(filename, replace_missing=True, replacement_file='blank_image.png', validate=True, **kwargs): +def uploaded_image( + filename, + replace_missing=True, + replacement_file='blank_image.png', + validate=True, + **kwargs, +): """Return a fully-qualified path for an 'uploaded' image. Arguments: @@ -167,12 +173,12 @@ def uploaded_image(filename, replace_missing=True, replacement_file='blank_image img = img.resize((width, height)) elif width is not None: # Resize the image, width only - wpercent = (width / float(img.size[0])) + wpercent = width / float(img.size[0]) hsize = int((float(img.size[1]) * float(wpercent))) img = img.resize((width, hsize)) elif height is not None: # Resize the image, height only - hpercent = (height / float(img.size[1])) + hpercent = height / float(img.size[1]) wsize = int((float(img.size[0]) * float(hpercent))) img = img.resize((wsize, height)) @@ -213,7 +219,9 @@ def encode_svg_image(filename): data = f.read() # Return the base64-encoded data - return "data:image/svg+xml;charset=utf-8;base64," + base64.b64encode(data).decode('utf-8') + return "data:image/svg+xml;charset=utf-8;base64," + base64.b64encode(data).decode( + 'utf-8' + ) @register.simple_tag() diff --git a/InvenTree/report/tests.py b/InvenTree/report/tests.py index 4f81623fc4..aa6c4fcb81 100644 --- a/InvenTree/report/tests.py +++ b/InvenTree/report/tests.py @@ -44,12 +44,7 @@ class ReportTagTest(TestCase): def test_getkey(self): """Tests for the 'getkey' template tag""" - data = { - 'hello': 'world', - 'foo': 'bar', - 'with spaces': 'withoutspaces', - 1: 2, - } + data = {'hello': 'world', 'foo': 'bar', 'with spaces': 'withoutspaces', 1: 2} for k, v in data.items(): self.assertEqual(report_tags.getkey(data, k), v) @@ -89,7 +84,9 @@ class ReportTagTest(TestCase): self.debug_mode(b) with self.assertRaises(FileNotFoundError): - report_tags.uploaded_image('/part/something/test.png', replace_missing=False) + report_tags.uploaded_image( + '/part/something/test.png', replace_missing=False + ) img = str(report_tags.uploaded_image('/part/something/other.png')) @@ -181,10 +178,7 @@ class BarcodeTagTest(TestCase): # Generate a much larger qrcode qrcode = barcode_tags.qrcode( - "hello_world", - version=2, - box_size=50, - format='BMP', + "hello_world", version=2, box_size=50, format='BMP' ) self.assertTrue(isinstance(qrcode, str)) self.assertTrue(qrcode.startswith('data:image/bmp;')) @@ -193,6 +187,7 @@ class BarcodeTagTest(TestCase): class ReportTest(InvenTreeAPITestCase): """Base class for unit testing reporting models""" + fixtures = [ 'category', 'part', @@ -217,16 +212,9 @@ class ReportTest(InvenTreeAPITestCase): def copyReportTemplate(self, filename, description): """Copy the provided report template into the required media directory.""" - src_dir = Path(__file__).parent.joinpath( - 'templates', - 'report' - ) + src_dir = Path(__file__).parent.joinpath('templates', 'report') - template_dir = os.path.join( - 'report', - 'inventree', - self.model.getSubdir(), - ) + template_dir = os.path.join('report', 'inventree', self.model.getSubdir()) dst_dir = settings.MEDIA_ROOT.joinpath(template_dir) @@ -240,17 +228,14 @@ class ReportTest(InvenTreeAPITestCase): shutil.copyfile(src_file, dst_file) # Convert to an "internal" filename - db_filename = os.path.join( - template_dir, - filename - ) + db_filename = os.path.join(template_dir, filename) # Create a database entry for this report template! self.model.objects.create( name=os.path.splitext(filename)[0], description=description, template=db_filename, - enabled=True + enabled=True, ) def test_list_endpoint(self): @@ -310,6 +295,7 @@ class ReportTest(InvenTreeAPITestCase): class TestReportTest(ReportTest): """Unit testing class for the stock item TestReport model""" + model = report_models.TestReport list_url = 'api-stockitem-testreport-list' @@ -363,6 +349,7 @@ class TestReportTest(ReportTest): class BuildReportTest(ReportTest): """Unit test class for the BuildReport model""" + model = report_models.BuildReport list_url = 'api-build-report-list' @@ -398,21 +385,28 @@ class BuildReportTest(ReportTest): headers = response.headers self.assertEqual(headers['Content-Type'], 'application/pdf') - self.assertEqual(headers['Content-Disposition'], 'attachment; filename="report.pdf"') + self.assertEqual( + headers['Content-Disposition'], 'attachment; filename="report.pdf"' + ) # Now, set the download type to be "inline" - inline = InvenTreeUserSetting.get_setting_object('REPORT_INLINE', cache=False, user=self.user) + inline = InvenTreeUserSetting.get_setting_object( + 'REPORT_INLINE', cache=False, user=self.user + ) inline.value = True inline.save() response = self.get(url, {'build': 1}) headers = response.headers self.assertEqual(headers['Content-Type'], 'application/pdf') - self.assertEqual(headers['Content-Disposition'], 'inline; filename="report.pdf"') + self.assertEqual( + headers['Content-Disposition'], 'inline; filename="report.pdf"' + ) class BOMReportTest(ReportTest): """Unit test class for the BillOfMaterialsReport model""" + model = report_models.BillOfMaterialsReport list_url = 'api-bom-report-list' @@ -421,13 +415,16 @@ class BOMReportTest(ReportTest): def setUp(self): """Setup function for the bill of materials Report""" - self.copyReportTemplate('inventree_bill_of_materials_report.html', 'bill of materials report') + self.copyReportTemplate( + 'inventree_bill_of_materials_report.html', 'bill of materials report' + ) return super().setUp() class PurchaseOrderReportTest(ReportTest): """Unit test class for the PurchaseOrderReport model""" + model = report_models.PurchaseOrderReport list_url = 'api-po-report-list' @@ -443,6 +440,7 @@ class PurchaseOrderReportTest(ReportTest): class SalesOrderReportTest(ReportTest): """Unit test class for the SalesOrderReport model""" + model = report_models.SalesOrderReport list_url = 'api-so-report-list' @@ -466,7 +464,9 @@ class ReturnOrderReportTest(ReportTest): def setUp(self): """Setup function for the ReturnOrderReport tests""" - self.copyReportTemplate('inventree_return_order_report.html', 'return order report') + self.copyReportTemplate( + 'inventree_return_order_report.html', 'return order report' + ) return super().setUp() diff --git a/InvenTree/script/translation_stats.py b/InvenTree/script/translation_stats.py index e46d682fae..2e197ae6aa 100644 --- a/InvenTree/script/translation_stats.py +++ b/InvenTree/script/translation_stats.py @@ -15,7 +15,6 @@ def calculate_coverage(filename): lines_uncovered = 0 for line in lines: - if line.startswith("msgid "): lines_count += 1 @@ -30,10 +29,11 @@ def calculate_coverage(filename): if __name__ == '__main__': - MY_DIR = os.path.dirname(os.path.realpath(__file__)) LC_DIR = os.path.abspath(os.path.join(MY_DIR, '..', 'locale')) - STAT_FILE = os.path.abspath(os.path.join(MY_DIR, '..', 'InvenTree/locale_stats.json')) + STAT_FILE = os.path.abspath( + os.path.join(MY_DIR, '..', 'InvenTree/locale_stats.json') + ) locales = {} locales_perc = {} @@ -43,7 +43,6 @@ if __name__ == '__main__': for locale in os.listdir(LC_DIR): path = os.path.join(LC_DIR, locale) if os.path.exists(path) and os.path.isdir(path): - locale_file = os.path.join(path, 'LC_MESSAGES', 'django.po') if os.path.exists(locale_file) and os.path.isfile(locale_file): diff --git a/InvenTree/stock/admin.py b/InvenTree/stock/admin.py index 939e074369..45a74f533e 100644 --- a/InvenTree/stock/admin.py +++ b/InvenTree/stock/admin.py @@ -14,8 +14,14 @@ from InvenTree.admin import InvenTreeResource from order.models import PurchaseOrder, SalesOrder from part.models import Part -from .models import (StockItem, StockItemAttachment, StockItemTestResult, - StockItemTracking, StockLocation, StockLocationType) +from .models import ( + StockItem, + StockItemAttachment, + StockItemTestResult, + StockItemTracking, + StockLocation, + StockLocationType, +) class LocationResource(InvenTreeResource): @@ -31,21 +37,39 @@ class LocationResource(InvenTreeResource): exclude = [ # Exclude MPTT internal model fields - 'lft', 'rght', 'tree_id', 'level', + 'lft', + 'rght', + 'tree_id', + 'level', 'metadata', - 'barcode_data', 'barcode_hash', - 'owner', 'icon', + 'barcode_data', + 'barcode_hash', + 'owner', + 'icon', ] - id = Field(attribute='id', column_name=_('Location ID'), widget=widgets.IntegerWidget()) + id = Field( + attribute='id', column_name=_('Location ID'), widget=widgets.IntegerWidget() + ) name = Field(attribute='name', column_name=_('Location Name')) description = Field(attribute='description', column_name=_('Description')) - parent = Field(attribute='parent', column_name=_('Parent ID'), widget=widgets.ForeignKeyWidget(StockLocation)) - parent_name = Field(attribute='parent__name', column_name=_('Parent Name'), readonly=True) + parent = Field( + attribute='parent', + column_name=_('Parent ID'), + widget=widgets.ForeignKeyWidget(StockLocation), + ) + parent_name = Field( + attribute='parent__name', column_name=_('Parent Name'), readonly=True + ) pathstring = Field(attribute='pathstring', column_name=_('Location Path')) # Calculated fields - items = Field(attribute='item_count', column_name=_('Stock Items'), widget=widgets.IntegerWidget(), readonly=True) + items = Field( + attribute='item_count', + column_name=_('Stock Items'), + widget=widgets.IntegerWidget(), + readonly=True, + ) def after_import(self, dataset, result, using_transactions, dry_run, **kwargs): """Rebuild after import to keep tree intact.""" @@ -57,6 +81,7 @@ class LocationResource(InvenTreeResource): class LocationInline(admin.TabularInline): """Inline for sub-locations.""" + model = StockLocation @@ -69,25 +94,23 @@ class LocationAdmin(ImportExportModelAdmin): search_fields = ('name', 'description') - inlines = [ - LocationInline, - ] + inlines = [LocationInline] - autocomplete_fields = [ - 'parent', - ] + autocomplete_fields = ['parent'] class LocationTypeAdmin(admin.ModelAdmin): """Admin class for StockLocationType.""" list_display = ('name', 'description', 'icon', 'location_count') - readonly_fields = ('location_count', ) + readonly_fields = ('location_count',) def get_queryset(self, request): """Annotate queryset to fetch location count.""" - return super().get_queryset(request).annotate( - location_count=Count("stock_locations"), + return ( + super() + .get_queryset(request) + .annotate(location_count=Count("stock_locations")) ) def location_count(self, obj): @@ -108,45 +131,129 @@ class StockItemResource(InvenTreeResource): exclude = [ # Exclude MPTT internal model fields - 'lft', 'rght', 'tree_id', 'level', + 'lft', + 'rght', + 'tree_id', + 'level', # Exclude internal fields - 'serial_int', 'metadata', - 'barcode_hash', 'barcode_data', + 'serial_int', + 'metadata', + 'barcode_hash', + 'barcode_data', 'owner', ] - id = Field(attribute='pk', column_name=_('Stock Item ID'), widget=widgets.IntegerWidget()) - part = Field(attribute='part', column_name=_('Part ID'), widget=widgets.ForeignKeyWidget(Part)) - part_name = Field(attribute='part__full_name', column_name=_('Part Name'), readonly=True) - quantity = Field(attribute='quantity', column_name=_('Quantity'), widget=widgets.DecimalWidget()) + id = Field( + attribute='pk', column_name=_('Stock Item ID'), widget=widgets.IntegerWidget() + ) + part = Field( + attribute='part', + column_name=_('Part ID'), + widget=widgets.ForeignKeyWidget(Part), + ) + part_name = Field( + attribute='part__full_name', column_name=_('Part Name'), readonly=True + ) + quantity = Field( + attribute='quantity', column_name=_('Quantity'), widget=widgets.DecimalWidget() + ) serial = Field(attribute='serial', column_name=_('Serial')) batch = Field(attribute='batch', column_name=_('Batch')) - status_label = Field(attribute='status_label', column_name=_('Status'), readonly=True) - status = Field(attribute='status', column_name=_('Status Code'), widget=widgets.IntegerWidget()) - location = Field(attribute='location', column_name=_('Location ID'), widget=widgets.ForeignKeyWidget(StockLocation)) - location_name = Field(attribute='location__name', column_name=_('Location Name'), readonly=True) - supplier_part = Field(attribute='supplier_part', column_name=_('Supplier Part ID'), widget=widgets.ForeignKeyWidget(SupplierPart)) - supplier = Field(attribute='supplier_part__supplier__id', column_name=_('Supplier ID'), readonly=True, widget=widgets.IntegerWidget()) - supplier_name = Field(attribute='supplier_part__supplier__name', column_name=_('Supplier Name'), readonly=True) - customer = Field(attribute='customer', column_name=_('Customer ID'), widget=widgets.ForeignKeyWidget(Company)) - belongs_to = Field(attribute='belongs_to', column_name=_('Installed In'), widget=widgets.ForeignKeyWidget(StockItem)) - build = Field(attribute='build', column_name=_('Build ID'), widget=widgets.ForeignKeyWidget(Build)) - parent = Field(attribute='parent', column_name=_('Parent ID'), widget=widgets.ForeignKeyWidget(StockItem)) - sales_order = Field(attribute='sales_order', column_name=_('Sales Order ID'), widget=widgets.ForeignKeyWidget(SalesOrder)) - purchase_order = Field(attribute='purchase_order', column_name=_('Purchase Order ID'), widget=widgets.ForeignKeyWidget(PurchaseOrder)) + status_label = Field( + attribute='status_label', column_name=_('Status'), readonly=True + ) + status = Field( + attribute='status', column_name=_('Status Code'), widget=widgets.IntegerWidget() + ) + location = Field( + attribute='location', + column_name=_('Location ID'), + widget=widgets.ForeignKeyWidget(StockLocation), + ) + location_name = Field( + attribute='location__name', column_name=_('Location Name'), readonly=True + ) + supplier_part = Field( + attribute='supplier_part', + column_name=_('Supplier Part ID'), + widget=widgets.ForeignKeyWidget(SupplierPart), + ) + supplier = Field( + attribute='supplier_part__supplier__id', + column_name=_('Supplier ID'), + readonly=True, + widget=widgets.IntegerWidget(), + ) + supplier_name = Field( + attribute='supplier_part__supplier__name', + column_name=_('Supplier Name'), + readonly=True, + ) + customer = Field( + attribute='customer', + column_name=_('Customer ID'), + widget=widgets.ForeignKeyWidget(Company), + ) + belongs_to = Field( + attribute='belongs_to', + column_name=_('Installed In'), + widget=widgets.ForeignKeyWidget(StockItem), + ) + build = Field( + attribute='build', + column_name=_('Build ID'), + widget=widgets.ForeignKeyWidget(Build), + ) + parent = Field( + attribute='parent', + column_name=_('Parent ID'), + widget=widgets.ForeignKeyWidget(StockItem), + ) + sales_order = Field( + attribute='sales_order', + column_name=_('Sales Order ID'), + widget=widgets.ForeignKeyWidget(SalesOrder), + ) + purchase_order = Field( + attribute='purchase_order', + column_name=_('Purchase Order ID'), + widget=widgets.ForeignKeyWidget(PurchaseOrder), + ) packaging = Field(attribute='packaging', column_name=_('Packaging')) link = Field(attribute='link', column_name=_('Link')) notes = Field(attribute='notes', column_name=_('Notes')) # Status fields (note that IntegerWidget exports better to excel than BooleanWidget) - is_building = Field(attribute='is_building', column_name=_('Building'), widget=widgets.IntegerWidget()) - review_needed = Field(attribute='review_needed', column_name=_('Review Needed'), widget=widgets.IntegerWidget()) - delete_on_deplete = Field(attribute='delete_on_deplete', column_name=_('Delete on Deplete'), widget=widgets.IntegerWidget()) + is_building = Field( + attribute='is_building', + column_name=_('Building'), + widget=widgets.IntegerWidget(), + ) + review_needed = Field( + attribute='review_needed', + column_name=_('Review Needed'), + widget=widgets.IntegerWidget(), + ) + delete_on_deplete = Field( + attribute='delete_on_deplete', + column_name=_('Delete on Deplete'), + widget=widgets.IntegerWidget(), + ) # Date management - updated = Field(attribute='updated', column_name=_('Last Updated'), widget=widgets.DateWidget()) - stocktake_date = Field(attribute='stocktake_date', column_name=_('Stocktake'), widget=widgets.DateWidget()) - expiry_date = Field(attribute='expiry_date', column_name=_('Expiry Date'), widget=widgets.DateWidget()) + updated = Field( + attribute='updated', column_name=_('Last Updated'), widget=widgets.DateWidget() + ) + stocktake_date = Field( + attribute='stocktake_date', + column_name=_('Stocktake'), + widget=widgets.DateWidget(), + ) + expiry_date = Field( + attribute='expiry_date', + column_name=_('Expiry Date'), + widget=widgets.DateWidget(), + ) def dehydrate_purchase_price(self, item): """Render purchase pric as float""" @@ -169,12 +276,7 @@ class StockItemAdmin(ImportExportModelAdmin): list_display = ('part', 'quantity', 'location', 'status', 'updated') # A list of search fields which can be used for lookup on matching 'autocomplete' fields - search_fields = [ - 'part__name', - 'part__description', - 'serial', - 'batch', - ] + search_fields = ['part__name', 'part__description', 'serial', 'batch'] autocomplete_fields = [ 'belongs_to', @@ -195,9 +297,7 @@ class StockAttachmentAdmin(admin.ModelAdmin): list_display = ('stock_item', 'attachment', 'comment') - autocomplete_fields = [ - 'stock_item', - ] + autocomplete_fields = ['stock_item'] class StockTrackingAdmin(ImportExportModelAdmin): @@ -205,9 +305,7 @@ class StockTrackingAdmin(ImportExportModelAdmin): list_display = ('item', 'date', 'label') - autocomplete_fields = [ - 'item', - ] + autocomplete_fields = ['item'] class StockItemTestResultAdmin(admin.ModelAdmin): @@ -215,9 +313,7 @@ class StockItemTestResultAdmin(admin.ModelAdmin): list_display = ('stock_item', 'test', 'result', 'value') - autocomplete_fields = [ - 'stock_item', - ] + autocomplete_fields = ['stock_item'] admin.site.register(StockLocation, LocationAdmin) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index a6b5bf6f38..d4b7ade93e 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -23,25 +23,51 @@ from build.serializers import BuildSerializer from company.models import Company, SupplierPart from company.serializers import CompanySerializer from generic.states.api import StatusView -from InvenTree.api import (APIDownloadMixin, AttachmentMixin, - ListCreateDestroyAPIView, MetadataView) -from InvenTree.filters import (ORDER_FILTER, SEARCH_ORDER_FILTER, - SEARCH_ORDER_FILTER_ALIAS, InvenTreeDateFilter) -from InvenTree.helpers import (DownloadFile, extract_serial_numbers, isNull, - str2bool, str2int) -from InvenTree.mixins import (CreateAPI, CustomRetrieveUpdateDestroyAPI, - ListAPI, ListCreateAPI, RetrieveAPI, - RetrieveUpdateDestroyAPI) +from InvenTree.api import ( + APIDownloadMixin, + AttachmentMixin, + ListCreateDestroyAPIView, + MetadataView, +) +from InvenTree.filters import ( + ORDER_FILTER, + SEARCH_ORDER_FILTER, + SEARCH_ORDER_FILTER_ALIAS, + InvenTreeDateFilter, +) +from InvenTree.helpers import ( + DownloadFile, + extract_serial_numbers, + isNull, + str2bool, + str2int, +) +from InvenTree.mixins import ( + CreateAPI, + CustomRetrieveUpdateDestroyAPI, + ListAPI, + ListCreateAPI, + RetrieveAPI, + RetrieveUpdateDestroyAPI, +) from InvenTree.status_codes import StockHistoryCode, StockStatus -from order.models import (PurchaseOrder, ReturnOrder, SalesOrder, - SalesOrderAllocation) -from order.serializers import (PurchaseOrderSerializer, ReturnOrderSerializer, - SalesOrderSerializer) +from order.models import PurchaseOrder, ReturnOrder, SalesOrder, SalesOrderAllocation +from order.serializers import ( + PurchaseOrderSerializer, + ReturnOrderSerializer, + SalesOrderSerializer, +) from part.models import BomItem, Part, PartCategory from part.serializers import PartBriefSerializer from stock.admin import LocationResource, StockItemResource -from stock.models import (StockItem, StockItemAttachment, StockItemTestResult, - StockItemTracking, StockLocation, StockLocationType) +from stock.models import ( + StockItem, + StockItemAttachment, + StockItemTestResult, + StockItemTracking, + StockLocation, + StockLocationType, +) class StockDetail(RetrieveUpdateDestroyAPI): @@ -83,7 +109,9 @@ class StockDetail(RetrieveUpdateDestroyAPI): kwargs['part_detail'] = str2bool(params.get('part_detail', True)) kwargs['location_detail'] = str2bool(params.get('location_detail', True)) - kwargs['supplier_part_detail'] = str2bool(params.get('supplier_part_detail', True)) + kwargs['supplier_part_detail'] = str2bool( + params.get('supplier_part_detail', True) + ) kwargs['path_detail'] = str2bool(params.get('path_detail', False)) except AttributeError: pass @@ -226,11 +254,12 @@ class StockLocationFilter(rest_filters.FilterSet): """Base class for custom API filters for the StockLocation endpoint.""" location_type = rest_filters.ModelChoiceFilter( - queryset=StockLocationType.objects.all(), - field_name='location_type' + queryset=StockLocationType.objects.all(), field_name='location_type' ) - has_location_type = rest_filters.BooleanFilter(label='has_location_type', method='filter_has_location_type') + has_location_type = rest_filters.BooleanFilter( + label='has_location_type', method='filter_has_location_type' + ) def filter_has_location_type(self, queryset, name, value): """Filter by whether or not the location has a location type""" @@ -246,9 +275,7 @@ class StockLocationList(APIDownloadMixin, ListCreateAPI): - POST: Create a new StockLocation """ - queryset = StockLocation.objects.all().prefetch_related( - 'tags', - ) + queryset = StockLocation.objects.all().prefetch_related('tags') serializer_class = StockSerializers.LocationSerializer filterset_class = StockLocationFilter @@ -283,7 +310,6 @@ class StockLocationList(APIDownloadMixin, ListCreateAPI): pass # Look for top-level locations elif isNull(loc_id): - # If we allow "cascade" at the top-level, this essentially means *all* locations if not cascade: queryset = queryset.filter(parent=None) @@ -292,7 +318,6 @@ class StockLocationList(APIDownloadMixin, ListCreateAPI): queryset = queryset.filter(level__lte=depth) else: - try: location = StockLocation.objects.get(pk=loc_id) @@ -319,7 +344,9 @@ class StockLocationList(APIDownloadMixin, ListCreateAPI): loc = StockLocation.objects.get(pk=exclude_tree) queryset = queryset.exclude( - pk__in=[subloc.pk for subloc in loc.get_descendants(include_self=True)] + pk__in=[ + subloc.pk for subloc in loc.get_descendants(include_self=True) + ] ) except (ValueError, StockLocation.DoesNotExist): @@ -329,35 +356,13 @@ class StockLocationList(APIDownloadMixin, ListCreateAPI): filter_backends = SEARCH_ORDER_FILTER - filterset_fields = [ - 'name', - 'structural', - 'external', - 'tags__name', - 'tags__slug', - ] + filterset_fields = ['name', 'structural', 'external', 'tags__name', 'tags__slug'] - search_fields = [ - 'name', - 'description', - 'tags__name', - 'tags__slug', - ] + search_fields = ['name', 'description', 'tags__name', 'tags__slug'] - ordering_fields = [ - 'name', - 'pathstring', - 'items', - 'level', - 'tree_id', - 'lft', - ] + ordering_fields = ['name', 'pathstring', 'items', 'level', 'tree_id', 'lft'] - ordering = [ - 'tree_id', - 'lft', - 'name', - ] + ordering = ['tree_id', 'lft', 'name'] class StockLocationTree(ListAPI): @@ -384,24 +389,18 @@ class StockLocationTypeList(ListCreateAPI): filter_backends = SEARCH_ORDER_FILTER - ordering_fields = [ - "name", - "location_count", - "icon", - ] + ordering_fields = ["name", "location_count", "icon"] - ordering = [ - "-location_count", - ] + ordering = ["-location_count"] - search_fields = [ - "name", - ] + search_fields = ["name"] def get_queryset(self): """Override the queryset method to include location count.""" queryset = super().get_queryset() - queryset = StockSerializers.StockLocationTypeSerializer.annotate_queryset(queryset) + queryset = StockSerializers.StockLocationTypeSerializer.annotate_queryset( + queryset + ) return queryset @@ -421,7 +420,9 @@ class StockLocationTypeDetail(RetrieveUpdateDestroyAPI): def get_queryset(self): """Override the queryset method to include location count.""" queryset = super().get_queryset() - queryset = StockSerializers.StockLocationTypeSerializer.annotate_queryset(queryset) + queryset = StockSerializers.StockLocationTypeSerializer.annotate_queryset( + queryset + ) return queryset @@ -448,26 +449,58 @@ class StockFilter(rest_filters.FilterSet): ] # Relationship filters - manufacturer = rest_filters.ModelChoiceFilter(label='Manufacturer', queryset=Company.objects.filter(is_manufacturer=True), field_name='manufacturer_part__manufacturer') - supplier = rest_filters.ModelChoiceFilter(label='Supplier', queryset=Company.objects.filter(is_supplier=True), field_name='supplier_part__supplier') + manufacturer = rest_filters.ModelChoiceFilter( + label='Manufacturer', + queryset=Company.objects.filter(is_manufacturer=True), + field_name='manufacturer_part__manufacturer', + ) + supplier = rest_filters.ModelChoiceFilter( + label='Supplier', + queryset=Company.objects.filter(is_supplier=True), + field_name='supplier_part__supplier', + ) # Part name filters - name = rest_filters.CharFilter(label='Part name (case insensitive)', field_name='part__name', lookup_expr='iexact') - name_contains = rest_filters.CharFilter(label='Part name contains (case insensitive)', field_name='part__name', lookup_expr='icontains') - name_regex = rest_filters.CharFilter(label='Part name (regex)', field_name='part__name', lookup_expr='iregex') + name = rest_filters.CharFilter( + label='Part name (case insensitive)', + field_name='part__name', + lookup_expr='iexact', + ) + name_contains = rest_filters.CharFilter( + label='Part name contains (case insensitive)', + field_name='part__name', + lookup_expr='icontains', + ) + name_regex = rest_filters.CharFilter( + label='Part name (regex)', field_name='part__name', lookup_expr='iregex' + ) # Part IPN filters - IPN = rest_filters.CharFilter(label='Part IPN (case insensitive)', field_name='part__IPN', lookup_expr='iexact') - IPN_contains = rest_filters.CharFilter(label='Part IPN contains (case insensitive)', field_name='part__IPN', lookup_expr='icontains') - IPN_regex = rest_filters.CharFilter(label='Part IPN (regex)', field_name='part__IPN', lookup_expr='iregex') + IPN = rest_filters.CharFilter( + label='Part IPN (case insensitive)', + field_name='part__IPN', + lookup_expr='iexact', + ) + IPN_contains = rest_filters.CharFilter( + label='Part IPN contains (case insensitive)', + field_name='part__IPN', + lookup_expr='icontains', + ) + IPN_regex = rest_filters.CharFilter( + label='Part IPN (regex)', field_name='part__IPN', lookup_expr='iregex' + ) # Part attribute filters assembly = rest_filters.BooleanFilter(label="Assembly", field_name='part__assembly') active = rest_filters.BooleanFilter(label="Active", field_name='part__active') salable = rest_filters.BooleanFilter(label="Salable", field_name='part__salable') - min_stock = rest_filters.NumberFilter(label='Minimum stock', field_name='quantity', lookup_expr='gte') - max_stock = rest_filters.NumberFilter(label='Maximum stock', field_name='quantity', lookup_expr='lte') + min_stock = rest_filters.NumberFilter( + label='Minimum stock', field_name='quantity', lookup_expr='gte' + ) + max_stock = rest_filters.NumberFilter( + label='Maximum stock', field_name='quantity', lookup_expr='lte' + ) status = rest_filters.NumberFilter(label='Status Code', method='filter_status') @@ -475,15 +508,21 @@ class StockFilter(rest_filters.FilterSet): """Filter by integer status code""" return queryset.filter(status=value) - allocated = rest_filters.BooleanFilter(label='Is Allocated', method='filter_allocated') + allocated = rest_filters.BooleanFilter( + label='Is Allocated', method='filter_allocated' + ) def filter_allocated(self, queryset, name, value): """Filter by whether or not the stock item is 'allocated'""" if str2bool(value): # Filter StockItem with either build allocations or sales order allocations - return queryset.filter(Q(sales_order_allocations__isnull=False) | Q(allocations__isnull=False)).distinct() + return queryset.filter( + Q(sales_order_allocations__isnull=False) | Q(allocations__isnull=False) + ).distinct() # Filter StockItem without build allocations or sales order allocations - return queryset.filter(Q(sales_order_allocations__isnull=True) & Q(allocations__isnull=True)) + return queryset.filter( + Q(sales_order_allocations__isnull=True) & Q(allocations__isnull=True) + ) expired = rest_filters.BooleanFilter(label='Expired', method='filter_expired') @@ -496,7 +535,9 @@ class StockFilter(rest_filters.FilterSet): return queryset.filter(StockItem.EXPIRED_FILTER) return queryset.exclude(StockItem.EXPIRED_FILTER) - external = rest_filters.BooleanFilter(label=_('External Location'), method='filter_external') + external = rest_filters.BooleanFilter( + label=_('External Location'), method='filter_external' + ) def filter_external(self, queryset, name, value): """Filter by whether or not the stock item is located in an external location""" @@ -522,23 +563,37 @@ class StockFilter(rest_filters.FilterSet): if str2bool(value): # The 'quantity' field is greater than the calculated 'allocated' field # Note that the item must also be "in stock" - return queryset.filter(StockItem.IN_STOCK_FILTER).filter(Q(quantity__gt=F('allocated'))) + return queryset.filter(StockItem.IN_STOCK_FILTER).filter( + Q(quantity__gt=F('allocated')) + ) # The 'quantity' field is less than (or equal to) the calculated 'allocated' field return queryset.filter(Q(quantity__lte=F('allocated'))) - batch = rest_filters.CharFilter(label="Batch code filter (case insensitive)", lookup_expr='iexact') + batch = rest_filters.CharFilter( + label="Batch code filter (case insensitive)", lookup_expr='iexact' + ) - batch_regex = rest_filters.CharFilter(label="Batch code filter (regex)", field_name='batch', lookup_expr='iregex') + batch_regex = rest_filters.CharFilter( + label="Batch code filter (regex)", field_name='batch', lookup_expr='iregex' + ) is_building = rest_filters.BooleanFilter(label="In production") # Serial number filtering - serial_gte = rest_filters.NumberFilter(label='Serial number GTE', field_name='serial_int', lookup_expr='gte') - serial_lte = rest_filters.NumberFilter(label='Serial number LTE', field_name='serial_int', lookup_expr='lte') + serial_gte = rest_filters.NumberFilter( + label='Serial number GTE', field_name='serial_int', lookup_expr='gte' + ) + serial_lte = rest_filters.NumberFilter( + label='Serial number LTE', field_name='serial_int', lookup_expr='lte' + ) - serial = rest_filters.CharFilter(label='Serial number', field_name='serial', lookup_expr='exact') + serial = rest_filters.CharFilter( + label='Serial number', field_name='serial', lookup_expr='exact' + ) - serialized = rest_filters.BooleanFilter(label='Has serial number', method='filter_serialized') + serialized = rest_filters.BooleanFilter( + label='Has serial number', method='filter_serialized' + ) def filter_serialized(self, queryset, name, value): """Filter by whether the StockItem has a serial number (or not).""" @@ -549,7 +604,9 @@ class StockFilter(rest_filters.FilterSet): return queryset.filter(q).distinct() - has_batch = rest_filters.BooleanFilter(label='Has batch code', method='filter_has_batch') + has_batch = rest_filters.BooleanFilter( + label='Has batch code', method='filter_has_batch' + ) def filter_has_batch(self, queryset, name, value): """Filter by whether the StockItem has a batch code (or not).""" @@ -577,7 +634,9 @@ class StockFilter(rest_filters.FilterSet): return queryset.filter(q_batch).filter(q_serial).distinct() - installed = rest_filters.BooleanFilter(label='Installed in other stock item', method='filter_installed') + installed = rest_filters.BooleanFilter( + label='Installed in other stock item', method='filter_installed' + ) def filter_installed(self, queryset, name, value): """Filter stock items by "belongs_to" field being empty.""" @@ -585,7 +644,9 @@ class StockFilter(rest_filters.FilterSet): return queryset.exclude(belongs_to=None) return queryset.filter(belongs_to=None) - has_installed_items = rest_filters.BooleanFilter(label='Has installed items', method='filter_has_installed') + has_installed_items = rest_filters.BooleanFilter( + label='Has installed items', method='filter_has_installed' + ) def filter_has_installed(self, queryset, name, value): """Filter stock items by "belongs_to" field being empty.""" @@ -593,7 +654,9 @@ class StockFilter(rest_filters.FilterSet): return queryset.filter(installed_items__gt=0) return queryset.filter(installed_items=0) - sent_to_customer = rest_filters.BooleanFilter(label='Sent to customer', method='filter_sent_to_customer') + sent_to_customer = rest_filters.BooleanFilter( + label='Sent to customer', method='filter_sent_to_customer' + ) def filter_sent_to_customer(self, queryset, name, value): """Filter by sent to customer.""" @@ -609,7 +672,9 @@ class StockFilter(rest_filters.FilterSet): return queryset.filter(quantity__lte=0) return queryset.exclude(quantity__lte=0) - has_purchase_price = rest_filters.BooleanFilter(label='Has purchase price', method='filter_has_purchase_price') + has_purchase_price = rest_filters.BooleanFilter( + label='Has purchase price', method='filter_has_purchase_price' + ) def filter_has_purchase_price(self, queryset, name, value): """Filter by having a purchase price.""" @@ -618,21 +683,17 @@ class StockFilter(rest_filters.FilterSet): return queryset.filter(purchase_price=None) ancestor = rest_filters.ModelChoiceFilter( - label='Ancestor', - queryset=StockItem.objects.all(), - method='filter_ancestor' + label='Ancestor', queryset=StockItem.objects.all(), method='filter_ancestor' ) def filter_ancestor(self, queryset, name, ancestor): """Filter based on ancestor stock item""" - return queryset.filter( - parent__in=ancestor.get_descendants(include_self=True) - ) + return queryset.filter(parent__in=ancestor.get_descendants(include_self=True)) category = rest_filters.ModelChoiceFilter( label=_('Category'), queryset=PartCategory.objects.all(), - method='filter_category' + method='filter_category', ) def filter_category(self, queryset, name, category): @@ -640,14 +701,10 @@ class StockFilter(rest_filters.FilterSet): child_categories = category.get_descendants(include_self=True) - return queryset.filter( - part__category__in=child_categories, - ) + return queryset.filter(part__category__in=child_categories) bom_item = rest_filters.ModelChoiceFilter( - label=_('BOM Item'), - queryset=BomItem.objects.all(), - method='filter_bom_item' + label=_('BOM Item'), queryset=BomItem.objects.all(), method='filter_bom_item' ) def filter_bom_item(self, queryset, name, bom_item): @@ -656,44 +713,39 @@ class StockFilter(rest_filters.FilterSet): return queryset.filter(bom_item.get_stock_filter()) part_tree = rest_filters.ModelChoiceFilter( - label=_('Part Tree'), - queryset=Part.objects.all(), - method='filter_part_tree' + label=_('Part Tree'), queryset=Part.objects.all(), method='filter_part_tree' ) def filter_part_tree(self, queryset, name, part_tree): """Filter based on part tree""" - return queryset.filter( - part__tree_id=part_tree.tree_id - ) + return queryset.filter(part__tree_id=part_tree.tree_id) company = rest_filters.ModelChoiceFilter( - label=_('Company'), - queryset=Company.objects.all(), - method='filter_company' + label=_('Company'), queryset=Company.objects.all(), method='filter_company' ) def filter_company(self, queryset, name, company): """Filter by company (either manufacturer or supplier)""" return queryset.filter( - Q(supplier_part__supplier=company) | Q(supplier_part__manufacturer_part__manufacturer=company) + Q(supplier_part__supplier=company) + | Q(supplier_part__manufacturer_part__manufacturer=company) ).distinct() # Update date filters - updated_before = InvenTreeDateFilter(label='Updated before', field_name='updated', lookup_expr='lte') - updated_after = InvenTreeDateFilter(label='Updated after', field_name='updated', lookup_expr='gte') + updated_before = InvenTreeDateFilter( + label='Updated before', field_name='updated', lookup_expr='lte' + ) + updated_after = InvenTreeDateFilter( + label='Updated after', field_name='updated', lookup_expr='gte' + ) # Stock "expiry" filters expiry_date_lte = InvenTreeDateFilter( - label=_("Expiry date before"), - field_name='expiry_date', - lookup_expr='lte', + label=_("Expiry date before"), field_name='expiry_date', lookup_expr='lte' ) expiry_date_gte = InvenTreeDateFilter( - label=_('Expiry date after'), - field_name='expiry_date', - lookup_expr='gte', + label=_('Expiry date after'), field_name='expiry_date', lookup_expr='gte' ) stale = rest_filters.BooleanFilter(label=_('Stale'), method='filter_stale') @@ -708,7 +760,11 @@ class StockFilter(rest_filters.FilterSet): return queryset stale_date = datetime.now().date() + timedelta(days=stale_days) - stale_filter = StockItem.IN_STOCK_FILTER & ~Q(expiry_date=None) & Q(expiry_date__lt=stale_date) + stale_filter = ( + StockItem.IN_STOCK_FILTER + & ~Q(expiry_date=None) + & Q(expiry_date__lt=stale_date) + ) if str2bool(value): return queryset.filter(stale_filter) @@ -740,7 +796,12 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): try: params = self.request.query_params - for key in ['part_detail', 'location_detail', 'supplier_part_detail', 'tests']: + for key in [ + 'part_detail', + 'location_detail', + 'supplier_part_detail', + 'tests', + ]: kwargs[key] = str2bool(params.get(key, False)) except AttributeError: pass @@ -774,17 +835,13 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): quantity = data.get('quantity', None) if quantity is None: - raise ValidationError({ - 'quantity': _('Quantity is required'), - }) + raise ValidationError({'quantity': _('Quantity is required')}) try: Part.objects.prefetch_related(None) part = Part.objects.get(pk=data.get('part', None)) except (ValueError, Part.DoesNotExist): - raise ValidationError({ - 'part': _('Valid part must be supplied'), - }) + raise ValidationError({'part': _('Valid part must be supplied')}) # Set default location (if not provided) if 'location' not in data: @@ -797,7 +854,9 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): # An expiry date was *not* specified - try to infer it! if expiry_date is None and part.default_expiry > 0: - data['expiry_date'] = datetime.now().date() + timedelta(days=part.default_expiry) + data['expiry_date'] = datetime.now().date() + timedelta( + days=part.default_expiry + ) # Attempt to extract serial numbers from submitted data serials = None @@ -808,10 +867,12 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): # Check if the supplier_part has a package size defined, which is not 1 if 'supplier_part' in data and data['supplier_part'] is not None: try: - supplier_part = SupplierPart.objects.get(pk=data.get('supplier_part', None)) + supplier_part = SupplierPart.objects.get( + pk=data.get('supplier_part', None) + ) except (ValueError, SupplierPart.DoesNotExist): raise ValidationError({ - 'supplier_part': _('The given supplier part does not exist'), + 'supplier_part': _('The given supplier part does not exist') }) if supplier_part.base_quantity() != 1: @@ -820,16 +881,25 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): # use_pack_size = False -> Use quantity as is if 'use_pack_size' not in data: raise ValidationError({ - 'use_pack_size': _('The supplier part has a pack size defined, but flag use_pack_size not set'), + 'use_pack_size': _( + 'The supplier part has a pack size defined, but flag use_pack_size not set' + ) }) else: if bool(data.get('use_pack_size')): - quantity = data['quantity'] = supplier_part.base_quantity(quantity) + quantity = data['quantity'] = supplier_part.base_quantity( + quantity + ) # Divide purchase price by pack size, to save correct price per stock item - if data['purchase_price'] and supplier_part.pack_quantity_native: + if ( + data['purchase_price'] + and supplier_part.pack_quantity_native + ): try: - data['purchase_price'] = float(data['purchase_price']) / float(supplier_part.pack_quantity_native) + data['purchase_price'] = float( + data['purchase_price'] + ) / float(supplier_part.pack_quantity_native) except ValueError: pass @@ -840,18 +910,17 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): # Assign serial numbers for a trackable part if serial_numbers: - if not part.trackable: raise ValidationError({ - 'serial_numbers': [_("Serial numbers cannot be supplied for a non-trackable part")] + 'serial_numbers': [ + _("Serial numbers cannot be supplied for a non-trackable part") + ] }) # If serial numbers are specified, check that they match! try: serials = extract_serial_numbers( - serial_numbers, - quantity, - part.get_latest_serial_number() + serial_numbers, quantity, part.get_latest_serial_number() ) # Determine if any of the specified serial numbers are invalid @@ -870,14 +939,11 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): errors.append(exc.message) if len(errors) > 0: - msg = _("The following serial numbers already exist or are invalid") msg += " : " msg += ",".join([str(e) for e in invalid]) - raise ValidationError({ - 'serial_numbers': errors + [msg] - }) + raise ValidationError({'serial_numbers': errors + [msg]}) except DjangoValidationError as e: raise ValidationError({ @@ -894,7 +960,6 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): serializer.is_valid(raise_exception=True) with transaction.atomic(): - # Create an initial StockItem object item = serializer.save() @@ -907,22 +972,22 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): if serials: for serial in serials[1:]: - # Create a duplicate stock item with the next serial number item.pk = None item.serial = serial item.save(user=user) - response_data = { - 'quantity': quantity, - 'serial_numbers': serials, - } + response_data = {'quantity': quantity, 'serial_numbers': serials} else: response_data = serializer.data - 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. @@ -999,23 +1064,16 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): exclude_so_allocation = params.get('exclude_so_allocation', None) if exclude_so_allocation is not None: - try: order = SalesOrder.objects.get(pk=exclude_so_allocation) # Grab all the active SalesOrderAllocations for this order allocations = SalesOrderAllocation.objects.filter( - line__pk__in=[ - line.pk for line in order.lines.all() - ] + line__pk__in=[line.pk for line in order.lines.all()] ) # Exclude any stock item which is already allocated to the sales order - queryset = queryset.exclude( - pk__in=[ - a.item.pk for a in allocations - ] - ) + queryset = queryset.exclude(pk__in=[a.item.pk for a in allocations]) except (ValueError, SalesOrder.DoesNotExist): pass @@ -1048,7 +1106,6 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): cascade = str2bool(params.get('cascade', True)) if loc_id is not None: - # Filter by 'null' location (i.e. top-level items) if isNull(loc_id): if not cascade: @@ -1058,7 +1115,9 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): # If '?cascade=true' then include items which exist in sub-locations if cascade: location = StockLocation.objects.get(pk=loc_id) - queryset = queryset.filter(location__in=location.getUniqueChildren()) + queryset = queryset.filter( + location__in=location.getUniqueChildren() + ) else: queryset = queryset.filter(location=loc_id) @@ -1089,11 +1148,7 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): 'SKU', ] - ordering = [ - 'part__name', - 'quantity', - 'location', - ] + ordering = ['part__name', 'quantity', 'location'] search_fields = [ 'serial', @@ -1113,9 +1168,7 @@ class StockAttachmentList(AttachmentMixin, ListCreateDestroyAPIView): queryset = StockItemAttachment.objects.all() serializer_class = StockSerializers.StockItemAttachmentSerializer - filterset_fields = [ - 'stock_item', - ] + filterset_fields = ['stock_item'] class StockAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI): @@ -1140,12 +1193,7 @@ class StockItemTestResultList(ListCreateDestroyAPIView): filter_backends = SEARCH_ORDER_FILTER - filterset_fields = [ - 'test', - 'user', - 'result', - 'value', - ] + filterset_fields = ['test', 'user', 'result', 'value'] ordering = 'date' @@ -1159,7 +1207,6 @@ class StockItemTestResultList(ListCreateDestroyAPIView): build = params.get('build', None) if build is not None: - try: build = Build.objects.get(pk=build) @@ -1197,7 +1244,9 @@ class StockItemTestResultList(ListCreateDestroyAPIView): def get_serializer(self, *args, **kwargs): """Set context before returning serializer.""" try: - kwargs['user_detail'] = str2bool(self.request.query_params.get('user_detail', False)) + kwargs['user_detail'] = str2bool( + self.request.query_params.get('user_detail', False) + ) except Exception: pass @@ -1239,12 +1288,16 @@ class StockTrackingList(ListAPI): def get_serializer(self, *args, **kwargs): """Set context before returning serializer.""" try: - kwargs['item_detail'] = str2bool(self.request.query_params.get('item_detail', False)) + kwargs['item_detail'] = str2bool( + self.request.query_params.get('item_detail', False) + ) except Exception: pass try: - kwargs['user_detail'] = str2bool(self.request.query_params.get('user_detail', False)) + kwargs['user_detail'] = str2bool( + self.request.query_params.get('user_detail', False) + ) except Exception: pass @@ -1372,25 +1425,19 @@ class StockTrackingList(ListAPI): item.save() headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + return Response( + serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) filter_backends = SEARCH_ORDER_FILTER - filterset_fields = [ - 'item', - 'user', - ] + filterset_fields = ['item', 'user'] ordering = '-date' - ordering_fields = [ - 'date', - ] + ordering_fields = ['date'] - search_fields = [ - 'title', - 'notes', - ] + search_fields = ['title', 'notes'] class LocationDetail(CustomRetrieveUpdateDestroyAPI): @@ -1435,36 +1482,59 @@ class LocationDetail(CustomRetrieveUpdateDestroyAPI): **dict( kwargs, delete_sub_locations=delete_sub_locations, - delete_stock_items=delete_stock_items - ) + delete_stock_items=delete_stock_items, + ), ) stock_api_urls = [ - re_path(r'^location/', include([ - - re_path(r'^tree/', StockLocationTree.as_view(), name='api-location-tree'), - - # Stock location detail endpoints - path(r'/', include([ - - re_path(r'^metadata/', MetadataView.as_view(), {'model': StockLocation}, name='api-location-metadata'), - - re_path(r'^.*$', LocationDetail.as_view(), name='api-location-detail'), - ])), - - re_path(r'^.*$', StockLocationList.as_view(), name='api-location-list'), - ])), - + re_path( + r'^location/', + include([ + re_path(r'^tree/', StockLocationTree.as_view(), name='api-location-tree'), + # Stock location detail endpoints + path( + r'/', + include([ + re_path( + r'^metadata/', + MetadataView.as_view(), + {'model': StockLocation}, + name='api-location-metadata', + ), + re_path( + r'^.*$', LocationDetail.as_view(), name='api-location-detail' + ), + ]), + ), + re_path(r'^.*$', StockLocationList.as_view(), name='api-location-list'), + ]), + ), # Stock location type endpoints - re_path(r'^location-type/', include([ - path(r'/', include([ - re_path(r'^metadata/', MetadataView.as_view(), {'model': StockLocationType}, name='api-location-type-metadata'), - re_path(r'^.*$', StockLocationTypeDetail.as_view(), name='api-location-type-detail'), - ])), - re_path(r'^.*$', StockLocationTypeList.as_view(), name="api-location-type-list"), - ])), - + re_path( + r'^location-type/', + include([ + path( + r'/', + include([ + re_path( + r'^metadata/', + MetadataView.as_view(), + {'model': StockLocationType}, + name='api-location-type-metadata', + ), + re_path( + r'^.*$', + StockLocationTypeDetail.as_view(), + name='api-location-type-detail', + ), + ]), + ), + re_path( + r'^.*$', StockLocationTypeList.as_view(), name="api-location-type-list" + ), + ]), + ), # Endpoints for bulk stock adjustment actions re_path(r'^count/', StockCount.as_view(), name='api-stock-count'), re_path(r'^add/', StockAdd.as_view(), name='api-stock-add'), @@ -1472,47 +1542,108 @@ stock_api_urls = [ re_path(r'^transfer/', StockTransfer.as_view(), name='api-stock-transfer'), re_path(r'^assign/', StockAssign.as_view(), name='api-stock-assign'), re_path(r'^merge/', StockMerge.as_view(), name='api-stock-merge'), - re_path(r'^change_status/', StockChangeStatus.as_view(), name='api-stock-change-status'), - + re_path( + r'^change_status/', StockChangeStatus.as_view(), name='api-stock-change-status' + ), # StockItemAttachment API endpoints - re_path(r'^attachment/', include([ - path(r'/', StockAttachmentDetail.as_view(), name='api-stock-attachment-detail'), - path('', StockAttachmentList.as_view(), name='api-stock-attachment-list'), - ])), - + re_path( + r'^attachment/', + include([ + path( + r'/', + StockAttachmentDetail.as_view(), + name='api-stock-attachment-detail', + ), + path('', StockAttachmentList.as_view(), name='api-stock-attachment-list'), + ]), + ), # StockItemTestResult API endpoints - re_path(r'^test/', include([ - path(r'/', include([ - re_path(r'^metadata/', MetadataView.as_view(), {'model': StockItemTestResult}, name='api-stock-test-result-metadata'), - re_path(r'^.*$', StockItemTestResultDetail.as_view(), name='api-stock-test-result-detail'), - ])), - re_path(r'^.*$', StockItemTestResultList.as_view(), name='api-stock-test-result-list'), - ])), - + re_path( + r'^test/', + include([ + path( + r'/', + include([ + re_path( + r'^metadata/', + MetadataView.as_view(), + {'model': StockItemTestResult}, + name='api-stock-test-result-metadata', + ), + re_path( + r'^.*$', + StockItemTestResultDetail.as_view(), + name='api-stock-test-result-detail', + ), + ]), + ), + re_path( + r'^.*$', + StockItemTestResultList.as_view(), + name='api-stock-test-result-list', + ), + ]), + ), # StockItemTracking API endpoints - re_path(r'^track/', include([ - path(r'/', StockTrackingDetail.as_view(), name='api-stock-tracking-detail'), - - # Stock tracking status code information - re_path(r'status/', StatusView.as_view(), {StatusView.MODEL_REF: StockHistoryCode}, name='api-stock-tracking-status-codes'), - - re_path(r'^.*$', StockTrackingList.as_view(), name='api-stock-tracking-list'), - ])), - + re_path( + r'^track/', + include([ + path( + r'/', + StockTrackingDetail.as_view(), + name='api-stock-tracking-detail', + ), + # Stock tracking status code information + re_path( + r'status/', + StatusView.as_view(), + {StatusView.MODEL_REF: StockHistoryCode}, + name='api-stock-tracking-status-codes', + ), + re_path( + r'^.*$', StockTrackingList.as_view(), name='api-stock-tracking-list' + ), + ]), + ), # Detail views for a single stock item - path(r'/', include([ - re_path(r'^convert/', StockItemConvert.as_view(), name='api-stock-item-convert'), - re_path(r'^install/', StockItemInstall.as_view(), name='api-stock-item-install'), - re_path(r'^metadata/', MetadataView.as_view(), {'model': StockItem}, name='api-stock-item-metadata'), - re_path(r'^return/', StockItemReturn.as_view(), name='api-stock-item-return'), - re_path(r'^serialize/', StockItemSerialize.as_view(), name='api-stock-item-serialize'), - re_path(r'^uninstall/', StockItemUninstall.as_view(), name='api-stock-item-uninstall'), - re_path(r'^.*$', StockDetail.as_view(), name='api-stock-detail'), - ])), - + path( + r'/', + include([ + re_path( + r'^convert/', StockItemConvert.as_view(), name='api-stock-item-convert' + ), + re_path( + r'^install/', StockItemInstall.as_view(), name='api-stock-item-install' + ), + re_path( + r'^metadata/', + MetadataView.as_view(), + {'model': StockItem}, + name='api-stock-item-metadata', + ), + re_path( + r'^return/', StockItemReturn.as_view(), name='api-stock-item-return' + ), + re_path( + r'^serialize/', + StockItemSerialize.as_view(), + name='api-stock-item-serialize', + ), + re_path( + r'^uninstall/', + StockItemUninstall.as_view(), + name='api-stock-item-uninstall', + ), + re_path(r'^.*$', StockDetail.as_view(), name='api-stock-detail'), + ]), + ), # Stock item status code information - re_path(r'status/', StatusView.as_view(), {StatusView.MODEL_REF: StockStatus}, name='api-stock-status-codes'), - + re_path( + r'status/', + StatusView.as_view(), + {StatusView.MODEL_REF: StockStatus}, + name='api-stock-status-codes', + ), # Anything else re_path(r'^.*$', StockList.as_view(), name='api-stock-list'), ] diff --git a/InvenTree/stock/apps.py b/InvenTree/stock/apps.py index 5c1d48545d..14ebab890f 100644 --- a/InvenTree/stock/apps.py +++ b/InvenTree/stock/apps.py @@ -5,4 +5,5 @@ from django.apps import AppConfig class StockConfig(AppConfig): """AppConfig for stock app.""" + name = 'stock' diff --git a/InvenTree/stock/filters.py b/InvenTree/stock/filters.py index 0d5207c801..57d0f0461b 100644 --- a/InvenTree/stock/filters.py +++ b/InvenTree/stock/filters.py @@ -31,5 +31,5 @@ def annotate_location_items(filter: Q = None): ).values('total') ), 0, - output_field=IntegerField() + output_field=IntegerField(), ) diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 8fbecd495c..b0fd9f4b28 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -31,11 +31,20 @@ import label.models import report.models from company import models as CompanyModels from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField -from InvenTree.models import (InvenTreeAttachment, InvenTreeBarcodeMixin, - InvenTreeNotesMixin, InvenTreeTree, - MetadataMixin, extract_int) -from InvenTree.status_codes import (SalesOrderStatusGroups, StockHistoryCode, - StockStatus, StockStatusGroups) +from InvenTree.models import ( + InvenTreeAttachment, + InvenTreeBarcodeMixin, + InvenTreeNotesMixin, + InvenTreeTree, + MetadataMixin, + extract_int, +) +from InvenTree.status_codes import ( + SalesOrderStatusGroups, + StockHistoryCode, + StockStatus, + StockStatusGroups, +) from part import models as PartModels from plugin.events import trigger_event from users.models import Owner @@ -66,24 +75,21 @@ class StockLocationType(MetadataMixin, models.Model): return self.name name = models.CharField( - blank=False, - max_length=100, - verbose_name=_("Name"), - help_text=_("Name"), + blank=False, max_length=100, verbose_name=_("Name"), help_text=_("Name") ) description = models.CharField( blank=True, max_length=250, verbose_name=_("Description"), - help_text=_("Description (optional)") + help_text=_("Description (optional)"), ) icon = models.CharField( blank=True, max_length=100, verbose_name=_("Icon"), - help_text=_("Default icon for all locations that have no icon set (optional)") + help_text=_("Default icon for all locations that have no icon set (optional)"), ) @@ -144,23 +150,29 @@ class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree): db_column="icon", ) - owner = models.ForeignKey(Owner, on_delete=models.SET_NULL, blank=True, null=True, - verbose_name=_('Owner'), - help_text=_('Select Owner'), - related_name='stock_locations') + owner = models.ForeignKey( + Owner, + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_('Owner'), + help_text=_('Select Owner'), + related_name='stock_locations', + ) structural = models.BooleanField( default=False, verbose_name=_('Structural'), help_text=_( 'Stock items may not be directly located into a structural stock locations, ' - 'but may be located to child locations.'), + 'but may be located to child locations.' + ), ) external = models.BooleanField( default=False, verbose_name=_('External'), - help_text=_('This is an external stock location') + help_text=_('This is an external stock location'), ) location_type = models.ForeignKey( @@ -168,7 +180,8 @@ class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree): on_delete=models.SET_NULL, verbose_name=_("Location type"), related_name="stock_locations", - null=True, blank=True, + null=True, + blank=True, help_text=_("Stock location type of this location"), ) @@ -212,7 +225,9 @@ class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree): if user.is_superuser: return True - ownership_enabled = common.models.InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL') + ownership_enabled = common.models.InvenTreeSetting.get_setting( + 'STOCK_OWNERSHIP_CONTROL' + ) if not ownership_enabled: # Location ownership function is not enabled, so return True @@ -234,8 +249,11 @@ class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree): """ if self.pk and self.structural and self.stock_item_count(False) > 0: raise ValidationError( - _("You cannot make this stock location structural because some stock items " - "are already located into it!")) + _( + "You cannot make this stock location structural because some stock items " + "are already located into it!" + ) + ) super().clean() def get_absolute_url(self): @@ -249,7 +267,9 @@ class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree): cascade: If True, also look under sublocations (default = True) """ if cascade: - query = StockItem.objects.filter(location__in=self.getUniqueChildren(include_self=True)) + query = StockItem.objects.filter( + location__in=self.getUniqueChildren(include_self=True) + ) else: query = StockItem.objects.filter(location=self.pk) @@ -292,7 +312,9 @@ def generate_batch_code(): return batch # If we get to this point, no plugin was able to generate a new batch code - batch_template = common.models.InvenTreeSetting.get_setting('STOCK_BATCH_CODE_TEMPLATE', '') + batch_template = common.models.InvenTreeSetting.get_setting( + 'STOCK_BATCH_CODE_TEMPLATE', '' + ) now = datetime.now() @@ -317,13 +339,21 @@ def default_delete_on_deplete(): Now, there is a user-configurable setting to govern default behaviour. """ try: - return common.models.InvenTreeSetting.get_setting('STOCK_DELETE_DEPLETED_DEFAULT', True) + return common.models.InvenTreeSetting.get_setting( + 'STOCK_DELETE_DEPLETED_DEFAULT', True + ) except (IntegrityError, OperationalError): # Revert to original default behaviour return True -class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, common.models.MetaMixin, MPTTModel): +class StockItem( + InvenTreeBarcodeMixin, + InvenTreeNotesMixin, + MetadataMixin, + common.models.MetaMixin, + MPTTModel, +): """A StockItem object represents a quantity of physical instances of a part. Attributes: @@ -358,11 +388,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo def api_instance_filters(self): """Custom API instance filters.""" - return { - 'parent': { - 'exclude_tree': self.pk, - } - } + return {'parent': {'exclude_tree': self.pk}} tags = TaggableManager(blank=True) @@ -374,11 +400,15 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo customer=None, consumed_by=None, is_building=False, - status__in=StockStatusGroups.AVAILABLE_CODES + status__in=StockStatusGroups.AVAILABLE_CODES, ) # A query filter which can be used to filter StockItem objects which have expired - EXPIRED_FILTER = IN_STOCK_FILTER & ~Q(expiry_date=None) & Q(expiry_date__lt=datetime.now().date()) + EXPIRED_FILTER = ( + IN_STOCK_FILTER + & ~Q(expiry_date=None) + & Q(expiry_date__lt=datetime.now().date()) + ) def update_serial_number(self): """Update the 'serial_int' field, to be an integer representation of the serial number. @@ -400,7 +430,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo if serial_int is not None: # Save the first returned result # Ensure that it is clipped within a range allowed in the database schema - clip = 0x7fffffff + clip = 0x7FFFFFFF serial_int = abs(serial_int) @@ -505,10 +535,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo if add_note and len(deltas) > 0: self.add_tracking_entry( - StockHistoryCode.EDITED, - user, - deltas=deltas, - notes=notes, + StockHistoryCode.EDITED, user, deltas=deltas, notes=notes ) except (ValueError, StockItem.DoesNotExist): @@ -518,10 +545,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo # If user information is provided, and no existing note exists, create one! if user and self.tracking_info.count() == 0: - - tracking_info = { - 'status': self.status, - } + tracking_info = {'status': self.status} self.add_tracking_entry( StockHistoryCode.CREATED, @@ -540,7 +564,11 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo @property def serialized(self): """Return True if this StockItem is serialized.""" - return self.serial is not None and len(str(self.serial).strip()) > 0 and self.quantity == 1 + return ( + self.serial is not None + and len(str(self.serial).strip()) > 0 + and self.quantity == 1 + ) def validate_unique(self, exclude=None): """Test that this StockItem is "unique". @@ -552,15 +580,12 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo # If the serial number is set, make sure it is not a duplicate if self.serial: - self.serial = str(self.serial).strip() try: self.part.validate_serial_number(self.serial, self, raise_error=True) except ValidationError as exc: - raise ValidationError({ - 'serial': exc.message, - }) + raise ValidationError({'serial': exc.message}) def validate_batch_code(self): """Ensure that the batch code is valid for this StockItem. @@ -574,9 +599,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo try: plugin.validate_batch_code(self.batch, self) except ValidationError as exc: - raise ValidationError({ - 'batch': exc.message - }) + raise ValidationError({'batch': exc.message}) def clean(self): """Validate the StockItem object (separate to field validation). @@ -589,8 +612,11 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo - Quantity must be 1 if the StockItem has a serial number """ if self.location is not None and self.location.structural: - raise ValidationError( - {'location': _("Stock items cannot be located into structural stock locations!")}) + raise ValidationError({ + 'location': _( + "Stock items cannot be located into structural stock locations!" + ) + }) super().clean() @@ -610,13 +636,15 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo if self.part.trackable: if self.quantity != int(self.quantity): raise ValidationError({ - 'quantity': _('Quantity must be integer value for trackable parts') + 'quantity': _( + 'Quantity must be integer value for trackable parts' + ) }) # Virtual parts cannot have stock items created against them if self.part.virtual: raise ValidationError({ - 'part': _("Stock item cannot be created for virtual parts"), + 'part': _("Stock item cannot be created for virtual parts") }) except PartModels.Part.DoesNotExist: # For some reason the 'clean' process sometimes throws errors because self.part does not exist @@ -625,24 +653,29 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo pass if self.quantity < 0: - raise ValidationError({ - 'quantity': _('Quantity must be greater than zero') - }) + raise ValidationError({'quantity': _('Quantity must be greater than zero')}) # The 'supplier_part' field must point to the same part! try: if self.supplier_part is not None: if self.supplier_part.part != self.part: - raise ValidationError({'supplier_part': _(f"Part type ('{self.supplier_part.part}') must be {self.part}") - }) + raise ValidationError({ + 'supplier_part': _( + f"Part type ('{self.supplier_part.part}') must be {self.part}" + ) + }) if self.part is not None: # A part with a serial number MUST have the quantity set to 1 if self.serial: if self.quantity > 1: raise ValidationError({ - 'quantity': _('Quantity must be 1 for item with a serial number'), - 'serial': _('Serial number cannot be set if quantity greater than 1') + 'quantity': _( + 'Quantity must be 1 for item with a serial number' + ), + 'serial': _( + 'Serial number cannot be set if quantity greater than 1' + ), }) if self.quantity == 0: @@ -650,7 +683,9 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo elif self.quantity > 1: raise ValidationError({ - 'quantity': _('Quantity must be 1 for item with a serial number') + 'quantity': _( + 'Quantity must be 1 for item with a serial number' + ) }) # Serial numbered items cannot be deleted on depletion @@ -663,9 +698,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo # Ensure that the item cannot be assigned to itself if self.belongs_to and self.belongs_to.pk == self.pk: - raise ValidationError({ - 'belongs_to': _('Item cannot belong to itself') - }) + raise ValidationError({'belongs_to': _('Item cannot belong to itself')}) # If the item is marked as "is_building", it must point to a build! if self.is_building and not self.build: @@ -675,7 +708,6 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo # If the item points to a build, check that the Part references match if self.build: - if self.part == self.build.part: # Part references match exactly pass @@ -700,21 +732,25 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo 'self', verbose_name=_('Parent Stock Item'), on_delete=models.DO_NOTHING, - blank=True, null=True, - related_name='children' + blank=True, + null=True, + related_name='children', ) part = models.ForeignKey( - 'part.Part', on_delete=models.CASCADE, + 'part.Part', + on_delete=models.CASCADE, verbose_name=_('Base Part'), related_name='stock_items', help_text=_('Base part'), - limit_choices_to={ - 'virtual': False - }) + limit_choices_to={'virtual': False}, + ) supplier_part = models.ForeignKey( - 'company.SupplierPart', blank=True, null=True, on_delete=models.SET_NULL, + 'company.SupplierPart', + blank=True, + null=True, + on_delete=models.SET_NULL, verbose_name=_('Supplier Part'), help_text=_('Select a matching supplier part for this stock item'), related_name='stock_items', @@ -722,18 +758,21 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo # Note: When a StockLocation is deleted, stock items are updated via a signal location = TreeForeignKey( - StockLocation, on_delete=models.DO_NOTHING, + StockLocation, + on_delete=models.DO_NOTHING, verbose_name=_('Stock Location'), related_name='stock_items', - blank=True, null=True, - help_text=_('Where is this stock item located?') + blank=True, + null=True, + help_text=_('Where is this stock item located?'), ) packaging = models.CharField( max_length=50, - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Packaging'), - help_text=_('Packaging this stock item is stored in') + help_text=_('Packaging this stock item is stored in'), ) # When deleting a stock item with installed items, those installed items are also installed @@ -741,14 +780,17 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo 'self', verbose_name=_('Installed In'), on_delete=models.CASCADE, - related_name='installed_parts', blank=True, null=True, - help_text=_('Is this item installed in another item?') + related_name='installed_parts', + blank=True, + null=True, + help_text=_('Is this item installed in another item?'), ) customer = models.ForeignKey( CompanyModels.Company, on_delete=models.SET_NULL, - null=True, blank=True, + null=True, + blank=True, limit_choices_to={'is_customer': True}, related_name='assigned_stock', help_text=_("Customer"), @@ -757,58 +799,65 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo serial = models.CharField( verbose_name=_('Serial Number'), - max_length=100, blank=True, null=True, - help_text=_('Serial number for this item') + max_length=100, + blank=True, + null=True, + help_text=_('Serial number for this item'), ) serial_int = models.IntegerField(default=0) link = InvenTreeURLField( - verbose_name=_('External Link'), - blank=True, - help_text=_("Link to external URL") + verbose_name=_('External Link'), blank=True, help_text=_("Link to external URL") ) batch = models.CharField( verbose_name=_('Batch Code'), - max_length=100, blank=True, null=True, + max_length=100, + blank=True, + null=True, help_text=_('Batch code for this stock item'), default=generate_batch_code, ) quantity = models.DecimalField( verbose_name=_("Stock Quantity"), - max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], - default=1 + max_digits=15, + decimal_places=5, + validators=[MinValueValidator(0)], + default=1, ) build = models.ForeignKey( - 'build.Build', on_delete=models.SET_NULL, + 'build.Build', + on_delete=models.SET_NULL, verbose_name=_('Source Build'), - blank=True, null=True, + blank=True, + null=True, help_text=_('Build for this stock item'), related_name='build_outputs', ) consumed_by = models.ForeignKey( - 'build.Build', on_delete=models.CASCADE, + 'build.Build', + on_delete=models.CASCADE, verbose_name=_('Consumed By'), - blank=True, null=True, + blank=True, + null=True, help_text=_('Build order which consumed this stock item'), related_name='consumed_stock', ) - is_building = models.BooleanField( - default=False, - ) + is_building = models.BooleanField(default=False) purchase_order = models.ForeignKey( 'order.PurchaseOrder', on_delete=models.SET_NULL, verbose_name=_('Source Purchase Order'), related_name='stock_items', - blank=True, null=True, - help_text=_('Purchase order for this stock item') + blank=True, + null=True, + help_text=_('Purchase order for this stock item'), ) sales_order = models.ForeignKey( @@ -816,33 +865,42 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo on_delete=models.SET_NULL, verbose_name=_("Destination Sales Order"), related_name='stock_items', - null=True, blank=True) + null=True, + blank=True, + ) expiry_date = models.DateField( - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Expiry Date'), - help_text=_('Expiry date for stock item. Stock will be considered expired after this date'), + help_text=_( + 'Expiry date for stock item. Stock will be considered expired after this date' + ), ) stocktake_date = models.DateField(blank=True, null=True) stocktake_user = models.ForeignKey( - User, on_delete=models.SET_NULL, - blank=True, null=True, - related_name='stocktake_stock' + User, + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name='stocktake_stock', ) review_needed = models.BooleanField(default=False) delete_on_deplete = models.BooleanField( default=default_delete_on_deplete, - verbose_name=_('Delete on deplete'), help_text=_('Delete this Stock Item when stock is depleted') + verbose_name=_('Delete on deplete'), + help_text=_('Delete this Stock Item when stock is depleted'), ) status = models.PositiveIntegerField( default=StockStatus.OK.value, choices=StockStatus.items(), - validators=[MinValueValidator(0)]) + validators=[MinValueValidator(0)], + ) @property def status_text(self): @@ -858,10 +916,15 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo help_text=_('Single unit purchase price at time of purchase'), ) - owner = models.ForeignKey(Owner, on_delete=models.SET_NULL, blank=True, null=True, - verbose_name=_('Owner'), - help_text=_('Select Owner'), - related_name='stock_items') + owner = models.ForeignKey( + Owner, + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_('Owner'), + help_text=_('Select Owner'), + related_name='stock_items', + ) @transaction.atomic def convert_to_variant(self, variant, user, notes=None): @@ -880,9 +943,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo self.add_tracking_entry( StockHistoryCode.CONVERTED_TO_VARIANT, user, - deltas={ - 'part': variant.pk, - }, + deltas={'part': variant.pk}, notes=_('Converted to part') + ': ' + variant.full_name, ) @@ -910,7 +971,9 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo if user.is_superuser: return True - ownership_enabled = common.models.InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL') + ownership_enabled = common.models.InvenTreeSetting.get_setting( + 'STOCK_OWNERSHIP_CONTROL' + ) if not ownership_enabled: # Location ownership function is not enabled, so return True @@ -979,7 +1042,9 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo # Delete outstanding BuildOrder allocations self.allocations.all().delete() - def allocateToCustomer(self, customer, quantity=None, order=None, user=None, notes=None): + def allocateToCustomer( + self, customer, quantity=None, order=None, user=None, notes=None + ): """Allocate a StockItem to a customer. This action can be called by the following processes: @@ -1020,18 +1085,9 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo code = StockHistoryCode.SHIPPED_AGAINST_SALES_ORDER deltas['salesorder'] = order.pk - item.add_tracking_entry( - code, - user, - deltas, - notes=notes, - ) + item.add_tracking_entry(code, user, deltas, notes=notes) - trigger_event( - 'stockitem.assignedtocustomer', - id=self.id, - customer=customer.id, - ) + trigger_event('stockitem.assignedtocustomer', id=self.id, customer=customer.id) # Return the reference to the stock item return item @@ -1045,9 +1101,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo """ notes = kwargs.get('notes', '') - tracking_info = { - 'location': location.pk, - } + tracking_info = {'location': location.pk} if self.customer: tracking_info['customer'] = self.customer.id @@ -1058,7 +1112,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo user, notes=notes, deltas=tracking_info, - location=location + location=location, ) self.customer = None @@ -1066,18 +1120,12 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo self.sales_order = None self.location = location - trigger_event( - 'stockitem.returnedfromcustomer', - id=self.id, - ) + trigger_event('stockitem.returnedfromcustomer', id=self.id) """If new location is the same as the parent location, merge this stock back in the parent""" if self.parent and self.location == self.parent.location: self.parent.merge_stock_items( - {self}, - user=user, - location=location, - notes=notes + {self}, user=user, location=location, notes=notes ) else: self.save(add_note=False) @@ -1123,14 +1171,12 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo if active is True: query = query.filter( line__order__status__in=SalesOrderStatusGroups.OPEN, - shipment__shipment_date=None + shipment__shipment_date=None, ) elif active is False: query = query.exclude( - line__order__status__in=SalesOrderStatusGroups.OPEN, - ).exclude( - shipment__shipment_date=None - ) + line__order__status__in=SalesOrderStatusGroups.OPEN + ).exclude(shipment__shipment_date=None) return query @@ -1190,7 +1236,6 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo items = StockItem.objects.filter(belongs_to=self) for item in items: - # Prevent duplication or recursion if item == self or item in installed: continue @@ -1201,7 +1246,6 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo sub_items = item.get_installed_items(cascade=True) for sub_item in sub_items: - # Prevent recursion if sub_item == self or sub_item in installed: continue @@ -1236,19 +1280,14 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo stock_item.consumed_by = build stock_item.save(add_note=False) - deltas = { - 'stockitem': self.pk, - } + deltas = {'stockitem': self.pk} if build is not None: deltas['buildorder'] = build.pk # Add a transaction note to the other item stock_item.add_tracking_entry( - StockHistoryCode.INSTALLED_INTO_ASSEMBLY, - user, - notes=notes, - deltas=deltas, + StockHistoryCode.INSTALLED_INTO_ASSEMBLY, user, notes=notes, deltas=deltas ) # Add a transaction note to this item (the assembly) @@ -1256,9 +1295,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo StockHistoryCode.INSTALLED_CHILD_ITEM, user, notes=notes, - deltas={ - 'stockitem': stock_item.pk, - } + deltas={'stockitem': stock_item.pk}, ) @transaction.atomic @@ -1280,15 +1317,11 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo self.belongs_to.add_tracking_entry( StockHistoryCode.REMOVED_CHILD_ITEM, user, - deltas={ - 'stockitem': self.pk, - }, + deltas={'stockitem': self.pk}, notes=notes, ) - tracking_info = { - 'stockitem': self.belongs_to.pk - } + tracking_info = {'stockitem': self.belongs_to.pk} self.add_tracking_entry( StockHistoryCode.REMOVED_FROM_ASSEMBLY, @@ -1359,7 +1392,14 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo """Is tracking info available?""" return self.tracking_info_count > 0 - def add_tracking_entry(self, entry_type: int, user: User, deltas: dict = None, notes: str = '', **kwargs): + def add_tracking_entry( + self, + entry_type: int, + user: User, + deltas: dict = None, + notes: str = '', + **kwargs, + ): """Add a history tracking entry for this StockItem. Args: @@ -1426,13 +1466,21 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo raise ValidationError({"quantity": _("Quantity must be greater than zero")}) if quantity > self.quantity: - raise ValidationError({"quantity": _(f"Quantity must not exceed available stock quantity ({self.quantity})")}) + raise ValidationError({ + "quantity": _( + f"Quantity must not exceed available stock quantity ({self.quantity})" + ) + }) if type(serials) not in [list, tuple]: - raise ValidationError({"serial_numbers": _("Serial numbers must be a list of integers")}) + raise ValidationError({ + "serial_numbers": _("Serial numbers must be a list of integers") + }) if quantity != len(serials): - raise ValidationError({"quantity": _("Quantity does not match serial numbers")}) + raise ValidationError({ + "quantity": _("Quantity does not match serial numbers") + }) # Test if each of the serial numbers are valid existing = self.part.find_conflicting_serial_numbers(serials) @@ -1444,7 +1492,6 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo # Create a new stock item for each unique serial number for serial in serials: - # Create a copy of this StockItem new_item = StockItem.objects.get(pk=self.pk) new_item.quantity = 1 @@ -1469,10 +1516,8 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo StockHistoryCode.ASSIGNED_SERIAL, user, notes=notes, - deltas={ - 'serial': serial, - }, - location=location + deltas={'serial': serial}, + location=location, ) # Remove the equivalent number of items @@ -1482,7 +1527,6 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo def copyHistoryFrom(self, other): """Copy stock history from another StockItem.""" for item in other.tracking_info.all(): - item.item = self item.pk = None item.save() @@ -1495,7 +1539,6 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo filters = {} for result in other.test_results.all().filter(**filters): - # Create a copy of the test result by nulling-out the pk result.pk = None result.stock_item = self @@ -1510,7 +1553,9 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo try: # Generic checks (do not rely on the 'other' part) if self.sales_order: - raise ValidationError(_('Stock item has been assigned to a sales order')) + raise ValidationError( + _('Stock item has been assigned to a sales order') + ) if self.belongs_to: raise ValidationError(_('Stock item is installed in another item')) @@ -1539,8 +1584,13 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo raise ValidationError(_("Stock items must refer to the same part")) # Check if supplier part references match - if self.supplier_part != other.supplier_part and not allow_mismatched_suppliers: - raise ValidationError(_("Stock items must refer to the same supplier part")) + if ( + self.supplier_part != other.supplier_part + and not allow_mismatched_suppliers + ): + raise ValidationError( + _("Stock items must refer to the same supplier part") + ) # Check if stock status codes match if self.status != other.status and not allow_mismatched_status: @@ -1579,18 +1629,15 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo return for other in other_items: - self.quantity += other.quantity # Any "build order allocations" for the other item must be assigned to this one for allocation in other.allocations.all(): - allocation.stock_item = self allocation.save() # Any "sales order allocations" for the other item must be assigned to this one for allocation in other.sales_order_allocations.all(): - allocation.stock_item = self() allocation.save() @@ -1606,9 +1653,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo user, quantity=self.quantity, notes=notes, - deltas={ - 'location': location.pk, - } + deltas={'location': location.pk}, ) self.location = location @@ -1669,9 +1714,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo else: new_stock.location = self.location - deltas = { - 'stockitem': self.pk, - } + deltas = {'stockitem': self.pk} # Optional fields which can be supplied in a 'move' call for field in StockItem.optional_transfer_fields(): @@ -1778,12 +1821,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo setattr(self, field, kwargs[field]) tracking_info[field] = kwargs[field] - self.add_tracking_entry( - tracking_code, - user, - notes=notes, - deltas=tracking_info, - ) + self.add_tracking_entry(tracking_code, user, notes=notes, deltas=tracking_info) self.save() @@ -1839,14 +1877,11 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo self.stocktake_user = user if self.updateQuantity(count): - self.add_tracking_entry( StockHistoryCode.STOCK_COUNT, user, notes=notes, - deltas={ - 'quantity': float(self.quantity), - } + deltas={'quantity': float(self.quantity)}, ) return True @@ -1872,21 +1907,19 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo return False if self.updateQuantity(self.quantity + quantity): - self.add_tracking_entry( StockHistoryCode.STOCK_ADD, user, notes=notes, - deltas={ - 'added': float(quantity), - 'quantity': float(self.quantity), - } + deltas={'added': float(quantity), 'quantity': float(self.quantity)}, ) return True @transaction.atomic - def take_stock(self, quantity, user, notes='', code=StockHistoryCode.STOCK_REMOVE, **kwargs): + def take_stock( + self, quantity, user, notes='', code=StockHistoryCode.STOCK_REMOVE, **kwargs + ): """Remove items from stock.""" # Cannot remove items from a serialized part if self.serialized: @@ -1901,11 +1934,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo return False if self.updateQuantity(self.quantity - quantity): - - deltas = { - 'removed': float(quantity), - 'quantity': float(self.quantity), - } + deltas = {'removed': float(quantity), 'quantity': float(self.quantity)} if location := kwargs.get('location', None): deltas['location'] = location.pk @@ -1913,12 +1942,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo if stockitem := kwargs.get('stockitem', None): deltas['stockitem'] = stockitem.pk - self.add_tracking_entry( - code, - user, - notes=notes, - deltas=deltas, - ) + self.add_tracking_entry(code, user, notes=notes, deltas=deltas) return True @@ -2042,11 +2066,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo else: failed += 1 - return { - 'total': total, - 'passed': passed, - 'failed': failed, - } + return {'total': total, 'passed': passed, 'failed': failed} @property def required_test_count(self): @@ -2070,7 +2090,6 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo item_query = StockItem.objects.filter(pk=self.pk) for test_report in report.models.TestReport.objects.filter(enabled=True): - # Attempt to validate report filter (skip if invalid) try: filters = InvenTree.helpers.validateFilterString(test_report.filters) @@ -2093,7 +2112,6 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo item_query = StockItem.objects.filter(pk=self.pk) for lbl in label.models.StockItemLabel.objects.filter(enabled=True): - try: filters = InvenTree.helpers.validateFilterString(lbl.filters) @@ -2130,7 +2148,9 @@ def after_delete_stock_item(sender, instance: StockItem, **kwargs): if not InvenTree.ready.isImportingData(): # Run this check in the background - InvenTree.tasks.offload_task(part_tasks.notify_low_stock_if_required, instance.part) + InvenTree.tasks.offload_task( + part_tasks.notify_low_stock_if_required, instance.part + ) # Schedule an update on parent part pricing if InvenTree.ready.canAppAccessDatabase(allow_test=True): @@ -2144,7 +2164,9 @@ def after_save_stock_item(sender, instance: StockItem, created, **kwargs): if created and not InvenTree.ready.isImportingData(): # Run this check in the background - InvenTree.tasks.offload_task(part_tasks.notify_low_stock_if_required, instance.part) + InvenTree.tasks.offload_task( + part_tasks.notify_low_stock_if_required, instance.part + ) if InvenTree.ready.canAppAccessDatabase(allow_test=True): instance.part.schedule_pricing_update(create=True) @@ -2163,9 +2185,7 @@ class StockItemAttachment(InvenTreeAttachment): return os.path.join("stock_files", str(self.stock_item.id)) stock_item = models.ForeignKey( - StockItem, - on_delete=models.CASCADE, - related_name='attachments' + StockItem, on_delete=models.CASCADE, related_name='attachments' ) @@ -2205,23 +2225,20 @@ class StockItemTracking(models.Model): return getattr(self, 'title', '') - tracking_type = models.IntegerField( - default=StockHistoryCode.LEGACY, - ) + tracking_type = models.IntegerField(default=StockHistoryCode.LEGACY) item = models.ForeignKey( - StockItem, - on_delete=models.CASCADE, - related_name='tracking_info' + StockItem, on_delete=models.CASCADE, related_name='tracking_info' ) date = models.DateTimeField(auto_now_add=True, editable=False) notes = models.CharField( - blank=True, null=True, + blank=True, + null=True, max_length=512, verbose_name=_('Notes'), - help_text=_('Entry notes') + help_text=_('Entry notes'), ) user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True) @@ -2231,7 +2248,9 @@ class StockItemTracking(models.Model): def rename_stock_item_test_result_attachment(instance, filename): """Rename test result.""" - return os.path.join('stock_files', str(instance.stock_item.pk), os.path.basename(filename)) + return os.path.join( + 'stock_files', str(instance.stock_item.pk), os.path.basename(filename) + ) class StockItemTestResult(MetadataMixin, models.Model): @@ -2275,17 +2294,16 @@ class StockItemTestResult(MetadataMixin, models.Model): for template in templates: if key == template.key: - if template.requires_value: if not self.value: raise ValidationError({ - "value": _("Value must be provided for this test"), + "value": _("Value must be provided for this test") }) if template.requires_attachment: if not self.attachment: raise ValidationError({ - "attachment": _("Attachment must be uploaded for this test"), + "attachment": _("Attachment must be uploaded for this test") }) break @@ -2296,49 +2314,36 @@ class StockItemTestResult(MetadataMixin, models.Model): return InvenTree.helpers.generateTestKey(self.test) stock_item = models.ForeignKey( - StockItem, - on_delete=models.CASCADE, - related_name='test_results' + StockItem, on_delete=models.CASCADE, related_name='test_results' ) test = models.CharField( - blank=False, max_length=100, - verbose_name=_('Test'), - help_text=_('Test name') + blank=False, max_length=100, verbose_name=_('Test'), help_text=_('Test name') ) result = models.BooleanField( - default=False, - verbose_name=_('Result'), - help_text=_('Test result') + default=False, verbose_name=_('Result'), help_text=_('Test result') ) value = models.CharField( - blank=True, max_length=500, + blank=True, + max_length=500, verbose_name=_('Value'), - help_text=_('Test output value') + help_text=_('Test output value'), ) attachment = models.FileField( - null=True, blank=True, + null=True, + blank=True, upload_to=rename_stock_item_test_result_attachment, verbose_name=_('Attachment'), help_text=_('Test result attachment'), ) notes = models.CharField( - blank=True, max_length=500, - verbose_name=_('Notes'), - help_text=_("Test notes"), + blank=True, max_length=500, verbose_name=_('Notes'), help_text=_("Test notes") ) - user = models.ForeignKey( - User, - on_delete=models.SET_NULL, - blank=True, null=True - ) + user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True) - date = models.DateTimeField( - auto_now_add=True, - editable=False - ) + date = models.DateTimeField(auto_now_add=True, editable=False) diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 593065f9ef..952b7b63ef 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -23,12 +23,17 @@ import part.models as part_models import stock.filters from company.serializers import SupplierPartSerializer from InvenTree.models import extract_int -from InvenTree.serializers import (InvenTreeCurrencySerializer, - InvenTreeDecimalField) +from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField from part.serializers import PartBriefSerializer -from .models import (StockItem, StockItemAttachment, StockItemTestResult, - StockItemTracking, StockLocation, StockLocationType) +from .models import ( + StockItem, + StockItemAttachment, + StockItemTestResult, + StockItemTracking, + StockLocation, + StockLocationType, +) class LocationBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer): @@ -38,11 +43,7 @@ class LocationBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Metaclass options.""" model = StockLocation - fields = [ - 'pk', - 'name', - 'pathstring', - ] + fields = ['pk', 'name', 'pathstring'] class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializer): @@ -64,15 +65,11 @@ class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializ 'notes', 'user', 'user_detail', - 'date' - ] - - read_only_fields = [ - 'pk', - 'user', 'date', ] + read_only_fields = ['pk', 'user', 'date'] + def __init__(self, *args, **kwargs): """Add detail fields.""" user_detail = kwargs.pop('user_detail', False) @@ -86,7 +83,9 @@ class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializ key = serializers.CharField(read_only=True) - attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=False) + attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField( + required=False + ) class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer): @@ -107,9 +106,7 @@ class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer): 'barcode_hash', ] - read_only_fields = [ - 'barcode_hash', - ] + read_only_fields = ['barcode_hash'] part_name = serializers.CharField(source='part.full_name', read_only=True) @@ -117,7 +114,7 @@ class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer): def validate_serial(self, value): """Make sure serial is not to big.""" - if abs(extract_int(value)) > 0x7fffffff: + if abs(extract_int(value)) > 0x7FFFFFFF: raise serializers.ValidationError(_("Serial number is too large")) return value @@ -169,14 +166,12 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer): 'purchase_price_currency', 'use_pack_size', 'tests', - # Annotated fields 'allocated', 'expired', 'installed_items', 'stale', 'tracking_items', - 'tags', ] @@ -195,21 +190,18 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer): """ Fields used when creating a stock item """ - extra_kwargs = { - 'use_pack_size': {'write_only': True}, - } + extra_kwargs = {'use_pack_size': {'write_only': True}} part = serializers.PrimaryKeyRelatedField( queryset=part_models.Part.objects.all(), - many=False, allow_null=False, + many=False, + allow_null=False, help_text=_("Base Part"), label=_("Part"), ) location_path = serializers.ListField( - child=serializers.DictField(), - source='location.get_path', - read_only=True, + child=serializers.DictField(), source='location.get_path', read_only=True ) """ @@ -219,7 +211,9 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer): write_only=True, required=False, allow_null=True, - help_text=_("Use pack size when adding: the quantity defined is the number of packs"), + help_text=_( + "Use pack size when adding: the quantity defined is the number of packs" + ), label=("Use pack size"), ) @@ -257,54 +251,63 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer): queryset = queryset.annotate( allocated=Coalesce( SubquerySum('sales_order_allocations__quantity'), Decimal(0) - ) + Coalesce( - SubquerySum('allocations__quantity'), Decimal(0) ) + + Coalesce(SubquerySum('allocations__quantity'), Decimal(0)) ) # Annotate the queryset with the number of tracking items - queryset = queryset.annotate( - tracking_items=SubqueryCount('tracking_info') - ) + queryset = queryset.annotate(tracking_items=SubqueryCount('tracking_info')) # Add flag to indicate if the StockItem has expired queryset = queryset.annotate( expired=Case( When( - StockItem.EXPIRED_FILTER, then=Value(True, output_field=BooleanField()), + StockItem.EXPIRED_FILTER, + then=Value(True, output_field=BooleanField()), ), - default=Value(False, output_field=BooleanField()) + default=Value(False, output_field=BooleanField()), ) ) # Add flag to indicate if the StockItem is stale stale_days = common.models.InvenTreeSetting.get_setting('STOCK_STALE_DAYS') stale_date = datetime.now().date() + timedelta(days=stale_days) - stale_filter = StockItem.IN_STOCK_FILTER & ~Q(expiry_date=None) & Q(expiry_date__lt=stale_date) + stale_filter = ( + StockItem.IN_STOCK_FILTER + & ~Q(expiry_date=None) + & Q(expiry_date__lt=stale_date) + ) queryset = queryset.annotate( stale=Case( - When( - stale_filter, then=Value(True, output_field=BooleanField()), - ), + When(stale_filter, then=Value(True, output_field=BooleanField())), default=Value(False, output_field=BooleanField()), ) ) # Annotate with the total number of "installed items" - queryset = queryset.annotate( - installed_items=SubqueryCount('installed_parts') - ) + queryset = queryset.annotate(installed_items=SubqueryCount('installed_parts')) return queryset status_text = serializers.CharField(source='get_status_display', read_only=True) # Optional detail fields, which can be appended via query parameters - supplier_part_detail = SupplierPartSerializer(source='supplier_part', supplier_detail=False, manufacturer_detail=False, part_detail=False, many=False, read_only=True) + supplier_part_detail = SupplierPartSerializer( + source='supplier_part', + supplier_detail=False, + manufacturer_detail=False, + part_detail=False, + many=False, + read_only=True, + ) part_detail = PartBriefSerializer(source='part', many=False, read_only=True) - location_detail = LocationBriefSerializer(source='location', many=False, read_only=True) - tests = StockItemTestResultSerializer(source='test_results', many=True, read_only=True) + location_detail = LocationBriefSerializer( + source='location', many=False, read_only=True + ) + tests = StockItemTestResultSerializer( + source='test_results', many=True, read_only=True + ) quantity = InvenTreeDecimalField() @@ -321,10 +324,16 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer): help_text=_('Purchase price of this stock item, per unit or pack'), ) - purchase_price_currency = InvenTreeCurrencySerializer(help_text=_('Purchase currency of this stock item')) + purchase_price_currency = InvenTreeCurrencySerializer( + help_text=_('Purchase currency of this stock item') + ) - purchase_order_reference = serializers.CharField(source='purchase_order.reference', read_only=True) - sales_order_reference = serializers.CharField(source='sales_order.reference', read_only=True) + purchase_order_reference = serializers.CharField( + source='purchase_order.reference', read_only=True + ) + sales_order_reference = serializers.CharField( + source='sales_order.reference', read_only=True + ) tags = TagListSerializerField(required=False) @@ -368,12 +377,7 @@ class SerializeStockItemSerializer(serializers.Serializer): class Meta: """Metaclass options.""" - fields = [ - 'quantity', - 'serial_numbers', - 'destination', - 'notes', - ] + fields = ['quantity', 'serial_numbers', 'destination', 'notes'] quantity = serializers.IntegerField( min_value=0, @@ -391,7 +395,9 @@ class SerializeStockItemSerializer(serializers.Serializer): if quantity > item.quantity: q = item.quantity - raise ValidationError(_(f"Quantity must not exceed available stock quantity ({q})")) + raise ValidationError( + _(f"Quantity must not exceed available stock quantity ({q})") + ) return quantity @@ -415,7 +421,7 @@ class SerializeStockItemSerializer(serializers.Serializer): required=False, allow_blank=True, label=_("Notes"), - help_text=_("Optional note field") + help_text=_("Optional note field"), ) def validate(self, data): @@ -433,14 +439,10 @@ class SerializeStockItemSerializer(serializers.Serializer): try: serials = InvenTree.helpers.extract_serial_numbers( - serial_numbers, - quantity, - item.part.get_latest_serial_number() + serial_numbers, quantity, item.part.get_latest_serial_number() ) except DjangoValidationError as e: - raise ValidationError({ - 'serial_numbers': e.messages, - }) + raise ValidationError({'serial_numbers': e.messages}) existing = item.part.find_conflicting_serial_numbers(serials) @@ -448,9 +450,7 @@ class SerializeStockItemSerializer(serializers.Serializer): exists = ','.join([str(x) for x in existing]) error = _('Serial numbers already exist') + ": " + exists - raise ValidationError({ - 'serial_numbers': error, - }) + raise ValidationError({'serial_numbers': error}) return data @@ -465,7 +465,7 @@ class SerializeStockItemSerializer(serializers.Serializer): serials = InvenTree.helpers.extract_serial_numbers( data['serial_numbers'], data['quantity'], - item.part.get_latest_serial_number() + item.part.get_latest_serial_number(), ) item.serializeStock( @@ -535,7 +535,9 @@ class InstallStockItemSerializer(serializers.Serializer): quantity = data.get('quantity', stock_item.quantity) if quantity > stock_item.quantity: - raise ValidationError(_("Quantity to install must not exceed available quantity")) + raise ValidationError( + _("Quantity to install must not exceed available quantity") + ) return data @@ -551,10 +553,7 @@ class InstallStockItemSerializer(serializers.Serializer): request = self.context['request'] parent_item.installStockItem( - stock_item, - quantity_to_install, - request.user, - note, + stock_item, quantity_to_install, request.user, note ) @@ -564,22 +563,22 @@ class UninstallStockItemSerializer(serializers.Serializer): class Meta: """Metaclass options.""" - fields = [ - 'location', - 'note', - ] + fields = ['location', 'note'] location = serializers.PrimaryKeyRelatedField( queryset=StockLocation.objects.all(), - many=False, required=True, allow_null=False, + many=False, + required=True, + allow_null=False, label=_('Location'), - help_text=_('Destination location for uninstalled item') + help_text=_('Destination location for uninstalled item'), ) note = serializers.CharField( label=_('Notes'), help_text=_('Add transaction note (optional)'), - required=False, allow_blank=True, + required=False, + allow_blank=True, ) def save(self): @@ -593,11 +592,7 @@ class UninstallStockItemSerializer(serializers.Serializer): note = data.get('note', '') - item.uninstall_into_location( - location, - request.user, - note - ) + item.uninstall_into_location(location, request.user, note) class ConvertStockItemSerializer(serializers.Serializer): @@ -605,15 +600,16 @@ class ConvertStockItemSerializer(serializers.Serializer): class Meta: """Metaclass options""" - fields = [ - 'part', - ] + + fields = ['part'] part = serializers.PrimaryKeyRelatedField( queryset=part_models.Part.objects.all(), label=_('Part'), help_text=_('Select part to convert stock item into'), - many=False, required=True, allow_null=False + many=False, + required=True, + allow_null=False, ) def validate_part(self, part): @@ -622,7 +618,9 @@ class ConvertStockItemSerializer(serializers.Serializer): valid_options = stock_item.part.get_conversion_options() if part not in valid_options: - raise ValidationError(_("Selected part is not a valid option for conversion")) + raise ValidationError( + _("Selected part is not a valid option for conversion") + ) return part @@ -636,7 +634,9 @@ class ConvertStockItemSerializer(serializers.Serializer): stock_item = self.context['item'] if stock_item.supplier_part is not None: - raise ValidationError(_("Cannot convert stock item with assigned SupplierPart")) + raise ValidationError( + _("Cannot convert stock item with assigned SupplierPart") + ) return data @@ -658,14 +658,13 @@ class ReturnStockItemSerializer(serializers.Serializer): class Meta: """Metaclass options""" - fields = [ - 'location', - 'note', - ] + fields = ['location', 'note'] location = serializers.PrimaryKeyRelatedField( queryset=StockLocation.objects.all(), - many=False, required=True, allow_null=False, + many=False, + required=True, + allow_null=False, label=_('Location'), help_text=_('Destination location for returned item'), ) @@ -673,7 +672,8 @@ class ReturnStockItemSerializer(serializers.Serializer): notes = serializers.CharField( label=_('Notes'), help_text=_('Add transaction note (optional)'), - required=False, allow_blank=True, + required=False, + allow_blank=True, ) def save(self): @@ -686,11 +686,7 @@ class ReturnStockItemSerializer(serializers.Serializer): location = data['location'] notes = data.get('notes', '') - item.return_from_customer( - location, - user=request.user, - notes=notes - ) + item.return_from_customer(location, user=request.user, notes=notes) class StockChangeStatusSerializer(serializers.Serializer): @@ -698,11 +694,8 @@ class StockChangeStatusSerializer(serializers.Serializer): class Meta: """Metaclass options""" - fields = [ - 'items', - 'status', - 'note', - ] + + fields = ['items', 'status', 'note'] items = serializers.PrimaryKeyRelatedField( queryset=StockItem.objects.all(), @@ -729,7 +722,8 @@ class StockChangeStatusSerializer(serializers.Serializer): note = serializers.CharField( label=_('Notes'), help_text=_('Add transaction note (optional)'), - required=False, allow_blank=True, + required=False, + allow_blank=True, ) @transaction.atomic @@ -748,9 +742,7 @@ class StockChangeStatusSerializer(serializers.Serializer): items_to_update = [] transaction_notes = [] - deltas = { - 'status': status, - } + deltas = {'status': status} now = datetime.now() @@ -792,26 +784,16 @@ class StockLocationTypeSerializer(InvenTree.serializers.InvenTreeModelSerializer """Serializer metaclass.""" model = StockLocationType - fields = [ - "pk", - "name", - "description", - "icon", - "location_count", - ] + fields = ["pk", "name", "description", "icon", "location_count"] - read_only_fields = [ - "location_count", - ] + read_only_fields = ["location_count"] location_count = serializers.IntegerField(read_only=True) @staticmethod def annotate_queryset(queryset): """Add location count to each location type.""" - return queryset.annotate( - location_count=Count("stock_locations") - ) + return queryset.annotate(location_count=Count("stock_locations")) class LocationTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer): @@ -821,13 +803,7 @@ class LocationTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Metaclass options.""" model = StockLocation - fields = [ - 'pk', - 'name', - 'parent', - 'icon', - 'structural', - ] + fields = ['pk', 'name', 'parent', 'icon', 'structural'] class LocationSerializer(InvenTree.serializers.InvenTreeTagModelSerializer): @@ -858,10 +834,7 @@ class LocationSerializer(InvenTree.serializers.InvenTreeTagModelSerializer): 'tags', ] - read_only_fields = [ - 'barcode_hash', - 'icon', - ] + read_only_fields = ['barcode_hash', 'icon'] def __init__(self, *args, **kwargs): """Optionally add or remove extra fields""" @@ -876,9 +849,7 @@ class LocationSerializer(InvenTree.serializers.InvenTreeTagModelSerializer): def annotate_queryset(queryset): """Annotate extra information to the queryset""" # Annotate the number of stock items which exist in this category (including subcategories) - queryset = queryset.annotate( - items=stock.filters.annotate_location_items() - ) + queryset = queryset.annotate(items=stock.filters.annotate_location_items()) return queryset @@ -891,19 +862,21 @@ class LocationSerializer(InvenTree.serializers.InvenTreeTagModelSerializer): tags = TagListSerializerField(required=False) path = serializers.ListField( - child=serializers.DictField(), - source='get_path', - read_only=True, + child=serializers.DictField(), source='get_path', read_only=True ) # explicitly set this field, so it gets included for AutoSchema icon = serializers.CharField(read_only=True) # Detail for location type - location_type_detail = StockLocationTypeSerializer(source="location_type", read_only=True, many=False) + location_type_detail = StockLocationTypeSerializer( + source="location_type", read_only=True, many=False + ) -class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer): +class StockItemAttachmentSerializer( + InvenTree.serializers.InvenTreeAttachmentSerializer +): """Serializer for StockItemAttachment model.""" class Meta: @@ -912,7 +885,7 @@ class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSer model = StockItemAttachment fields = InvenTree.serializers.InvenTreeAttachmentSerializer.attachment_fields([ - 'stock_item', + 'stock_item' ]) @@ -936,12 +909,7 @@ class StockTrackingSerializer(InvenTree.serializers.InvenTreeModelSerializer): 'user_detail', ] - read_only_fields = [ - 'date', - 'user', - 'label', - 'tracking_type', - ] + read_only_fields = ['date', 'user', 'label', 'tracking_type'] def __init__(self, *args, **kwargs): """Add detail fields.""" @@ -960,7 +928,9 @@ class StockTrackingSerializer(InvenTree.serializers.InvenTreeModelSerializer): item_detail = StockItemSerializerBrief(source='item', many=False, read_only=True) - user_detail = InvenTree.serializers.UserSerializer(source='user', many=False, read_only=True) + user_detail = InvenTree.serializers.UserSerializer( + source='user', many=False, read_only=True + ) deltas = serializers.JSONField(read_only=True) @@ -977,9 +947,7 @@ class StockAssignmentItemSerializer(serializers.Serializer): class Meta: """Metaclass options.""" - fields = [ - 'item', - ] + fields = ['item'] item = serializers.PrimaryKeyRelatedField( queryset=StockItem.objects.all(), @@ -1025,16 +993,9 @@ class StockAssignmentSerializer(serializers.Serializer): class Meta: """Metaclass options.""" - fields = [ - 'items', - 'customer', - 'notes', - ] + fields = ['items', 'customer', 'notes'] - items = StockAssignmentItemSerializer( - many=True, - required=True, - ) + items = StockAssignmentItemSerializer(many=True, required=True) customer = serializers.PrimaryKeyRelatedField( queryset=company.models.Company.objects.all(), @@ -1084,14 +1045,9 @@ class StockAssignmentSerializer(serializers.Serializer): with transaction.atomic(): for item in items: - stock_item = item['item'] - stock_item.allocateToCustomer( - customer, - user=user, - notes=notes, - ) + stock_item.allocateToCustomer(customer, user=user, notes=notes) class StockMergeItemSerializer(serializers.Serializer): @@ -1103,9 +1059,7 @@ class StockMergeItemSerializer(serializers.Serializer): class Meta: """Metaclass options.""" - fields = [ - 'item', - ] + fields = ['item'] item = serializers.PrimaryKeyRelatedField( queryset=StockItem.objects.all(), @@ -1137,10 +1091,7 @@ class StockMergeSerializer(serializers.Serializer): 'allow_mismatched_status', ] - items = StockMergeItemSerializer( - many=True, - required=True, - ) + items = StockMergeItemSerializer(many=True, required=True) location = serializers.PrimaryKeyRelatedField( queryset=StockLocation.objects.all(), @@ -1203,7 +1154,9 @@ class StockMergeSerializer(serializers.Serializer): item.can_merge( raise_error=True, other=base_item, - allow_mismatched_suppliers=data.get('allow_mismatched_suppliers', False), + allow_mismatched_suppliers=data.get( + 'allow_mismatched_suppliers', False + ), allow_mismatched_status=data.get('allow_mismatched_status', False), ) @@ -1233,7 +1186,7 @@ class StockMergeSerializer(serializers.Serializer): allow_mismatched_status=data.get('allow_mismatched_status', False), user=user, location=data['location'], - notes=data.get('notes', None) + notes=data.get('notes', None), ) @@ -1255,10 +1208,7 @@ class StockAdjustmentItemSerializer(serializers.Serializer): class Meta: """Metaclass options.""" - fields = [ - 'item', - 'quantity' - ] + fields = ['item', 'quantity'] pk = serializers.PrimaryKeyRelatedField( queryset=StockItem.objects.all(), @@ -1266,19 +1216,17 @@ class StockAdjustmentItemSerializer(serializers.Serializer): allow_null=False, required=True, label='stock_item', - help_text=_('StockItem primary key value') + help_text=_('StockItem primary key value'), ) quantity = serializers.DecimalField( - max_digits=15, - decimal_places=5, - min_value=0, - required=True + max_digits=15, decimal_places=5, min_value=0, required=True ) batch = serializers.CharField( max_length=100, - required=False, allow_blank=True, + required=False, + allow_blank=True, label=_('Batch Code'), help_text=_('Batch code for this stock item'), ) @@ -1288,12 +1236,14 @@ class StockAdjustmentItemSerializer(serializers.Serializer): default=InvenTree.status_codes.StockStatus.OK.value, label=_('Status'), help_text=_('Stock item status code'), - required=False, allow_blank=True, + required=False, + allow_blank=True, ) packaging = serializers.CharField( max_length=50, - required=False, allow_blank=True, + required=False, + allow_blank=True, label=_('Packaging'), help_text=_('Packaging this stock item is stored in'), ) @@ -1305,10 +1255,7 @@ class StockAdjustmentSerializer(serializers.Serializer): class Meta: """Metaclass options.""" - fields = [ - 'items', - 'notes', - ] + fields = ['items', 'notes'] items = StockAdjustmentItemSerializer(many=True) @@ -1344,15 +1291,10 @@ class StockCountSerializer(StockAdjustmentSerializer): with transaction.atomic(): for item in items: - stock_item = item['pk'] quantity = item['quantity'] - stock_item.stocktake( - quantity, - request.user, - notes=notes - ) + stock_item.stocktake(quantity, request.user, notes=notes) class StockAddSerializer(StockAdjustmentSerializer): @@ -1367,15 +1309,10 @@ class StockAddSerializer(StockAdjustmentSerializer): with transaction.atomic(): for item in data['items']: - stock_item = item['pk'] quantity = item['quantity'] - stock_item.add_stock( - quantity, - request.user, - notes=notes - ) + stock_item.add_stock(quantity, request.user, notes=notes) class StockRemoveSerializer(StockAdjustmentSerializer): @@ -1390,15 +1327,10 @@ class StockRemoveSerializer(StockAdjustmentSerializer): with transaction.atomic(): for item in data['items']: - stock_item = item['pk'] quantity = item['quantity'] - stock_item.take_stock( - quantity, - request.user, - notes=notes - ) + stock_item.take_stock(quantity, request.user, notes=notes) class StockTransferSerializer(StockAdjustmentSerializer): @@ -1407,11 +1339,7 @@ class StockTransferSerializer(StockAdjustmentSerializer): class Meta: """Metaclass options.""" - fields = [ - 'items', - 'notes', - 'location', - ] + fields = ['items', 'notes', 'location'] location = serializers.PrimaryKeyRelatedField( queryset=StockLocation.objects.all(), @@ -1434,7 +1362,6 @@ class StockTransferSerializer(StockAdjustmentSerializer): with transaction.atomic(): for item in items: - # Required fields stock_item = item['pk'] quantity = item['quantity'] @@ -1447,9 +1374,5 @@ class StockTransferSerializer(StockAdjustmentSerializer): kwargs[field_name] = item[field_name] stock_item.move( - location, - notes, - request.user, - quantity=quantity, - **kwargs + location, notes, request.user, quantity=quantity, **kwargs ) diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index 0243aaa405..6070ff4ab9 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -20,8 +20,12 @@ from common.models import InvenTreeSetting from InvenTree.status_codes import StockHistoryCode, StockStatus from InvenTree.unit_test import InvenTreeAPITestCase from part.models import Part -from stock.models import (StockItem, StockItemTestResult, StockLocation, - StockLocationType) +from stock.models import ( + StockItem, + StockItemTestResult, + StockLocation, + StockLocationType, +) class StockAPITestCase(InvenTreeAPITestCase): @@ -69,17 +73,57 @@ class StockLocationTest(StockAPITestCase): ({'parent': 1, 'cascade': True}, 2, 'Filter by parent, cascading'), ({'cascade': True, 'depth': 0}, 8, 'Cascade with no parent, depth=0'), ({'cascade': False, 'depth': 10}, 8, 'Cascade with no parent, depth=0'), - ({'parent': 'null', 'cascade': True, 'depth': 0}, 7, 'Cascade with null parent, depth=0'), - ({'parent': 'null', 'cascade': True, 'depth': 10}, 8, 'Cascade with null parent and bigger depth'), - ({'parent': 'null', 'cascade': False, 'depth': 10}, 3, 'No cascade even with depth specified with null parent'), - ({'parent': 1, 'cascade': False, 'depth': 0}, 2, 'Dont cascade with depth=0 and parent'), - ({'parent': 1, 'cascade': True, 'depth': 0}, 2, 'Cascade with depth=0 and parent'), - ({'parent': 1, 'cascade': False, 'depth': 1}, 2, 'Dont cascade even with depth=1 specified with parent'), - ({'parent': 1, 'cascade': True, 'depth': 1}, 2, 'Cascade with depth=1 with parent'), - ({'parent': 1, 'cascade': True, 'depth': 'abcdefg'}, 2, 'Cascade with invalid depth and parent'), + ( + {'parent': 'null', 'cascade': True, 'depth': 0}, + 7, + 'Cascade with null parent, depth=0', + ), + ( + {'parent': 'null', 'cascade': True, 'depth': 10}, + 8, + 'Cascade with null parent and bigger depth', + ), + ( + {'parent': 'null', 'cascade': False, 'depth': 10}, + 3, + 'No cascade even with depth specified with null parent', + ), + ( + {'parent': 1, 'cascade': False, 'depth': 0}, + 2, + 'Dont cascade with depth=0 and parent', + ), + ( + {'parent': 1, 'cascade': True, 'depth': 0}, + 2, + 'Cascade with depth=0 and parent', + ), + ( + {'parent': 1, 'cascade': False, 'depth': 1}, + 2, + 'Dont cascade even with depth=1 specified with parent', + ), + ( + {'parent': 1, 'cascade': True, 'depth': 1}, + 2, + 'Cascade with depth=1 with parent', + ), + ( + {'parent': 1, 'cascade': True, 'depth': 'abcdefg'}, + 2, + 'Cascade with invalid depth and parent', + ), ({'parent': 42}, 8, 'Should return everything if parent_pk is not valid'), - ({'parent': 'null', 'exclude_tree': 1, 'cascade': True}, 5, 'Should return everything except tree with pk=1'), - ({'parent': 'null', 'exclude_tree': 42, 'cascade': True}, 8, 'Should return everything because exclude_tree=42 is no valid pk'), + ( + {'parent': 'null', 'exclude_tree': 1, 'cascade': True}, + 5, + 'Should return everything except tree with pk=1', + ), + ( + {'parent': 'null', 'exclude_tree': 42, 'cascade': True}, + 8, + 'Should return everything because exclude_tree=42 is no valid pk', + ), ] for params, res_len, description in test_cases: @@ -105,7 +149,9 @@ class StockLocationTest(StockAPITestCase): response = self.get(self.list_url, expected_code=200) for result in response.data: for f in fields: - self.assertIn(f, result, f'"{f}" is missing in result of StockLocation list') + self.assertIn( + f, result, f'"{f}" is missing in result of StockLocation list' + ) def test_add(self): """Test adding StockLocation.""" @@ -113,7 +159,7 @@ class StockLocationTest(StockAPITestCase): data = { 'parent': 1, 'name': 'Location', - 'description': 'Another location for stock' + 'description': 'Another location for stock', } self.post(self.list_url, data, expected_code=201) @@ -122,14 +168,15 @@ class StockLocationTest(StockAPITestCase): """Test stock location deletion with different parameters""" class Target(IntEnum): - move_sub_locations_to_parent_move_stockitems_to_parent = 0, - move_sub_locations_to_parent_delete_stockitems = 1, - delete_sub_locations_move_stockitems_to_parent = 2, - delete_sub_locations_delete_stockitems = 3, + move_sub_locations_to_parent_move_stockitems_to_parent = (0,) + move_sub_locations_to_parent_delete_stockitems = (1,) + delete_sub_locations_move_stockitems_to_parent = (2,) + delete_sub_locations_delete_stockitems = (3,) # First, construct a set of template / variant parts part = Part.objects.create( - name='Part for stock item creation', description='Part for stock item creation', + name='Part for stock item creation', + description='Part for stock item creation', category=None, is_template=False, ) @@ -138,16 +185,22 @@ class StockLocationTest(StockAPITestCase): delete_sub_locations: bool = False delete_stock_items: bool = False - if i in (Target.move_sub_locations_to_parent_delete_stockitems, Target.delete_sub_locations_delete_stockitems): + if i in ( + Target.move_sub_locations_to_parent_delete_stockitems, + Target.delete_sub_locations_delete_stockitems, + ): delete_stock_items = True - if i in (Target.delete_sub_locations_move_stockitems_to_parent, Target.delete_sub_locations_delete_stockitems): + if i in ( + Target.delete_sub_locations_move_stockitems_to_parent, + Target.delete_sub_locations_delete_stockitems, + ): delete_sub_locations = True # Create a parent stock location parent_stock_location = StockLocation.objects.create( name='Parent stock location', description='This is the parent stock location where the sub categories and stock items are moved to', - parent=None + parent=None, ) stocklocation_count_before = StockLocation.objects.count() @@ -157,19 +210,23 @@ class StockLocationTest(StockAPITestCase): stock_location_to_delete = StockLocation.objects.create( name='Stock location to delete', description='This is the stock location to be deleted', - parent=parent_stock_location + parent=parent_stock_location, ) - url = reverse('api-location-detail', kwargs={'pk': stock_location_to_delete.id}) + url = reverse( + 'api-location-detail', kwargs={'pk': stock_location_to_delete.id} + ) stock_items = [] # Create stock items in the location to be deleted for jj in range(3): - stock_items.append(StockItem.objects.create( - batch=f"Batch xyz {jj}", - location=stock_location_to_delete, - part=part - )) + stock_items.append( + StockItem.objects.create( + batch=f"Batch xyz {jj}", + location=stock_location_to_delete, + part=part, + ) + ) child_stock_locations = [] child_stock_locations_items = [] @@ -178,17 +235,17 @@ class StockLocationTest(StockAPITestCase): child = StockLocation.objects.create( name=f"Sub-location {ii}", description="A sub-location of the deleted stock location", - parent=stock_location_to_delete + parent=stock_location_to_delete, ) child_stock_locations.append(child) # Create stock items in the sub locations for jj in range(3): - child_stock_locations_items.append(StockItem.objects.create( - batch=f"B xyz {jj}", - part=part, - location=child - )) + child_stock_locations_items.append( + StockItem.objects.create( + batch=f"B xyz {jj}", part=part, location=child + ) + ) # Delete the created stock location params = {} @@ -196,21 +253,22 @@ class StockLocationTest(StockAPITestCase): params['delete_stock_items'] = '1' if delete_sub_locations: params['delete_sub_locations'] = '1' - response = self.delete( - url, - params, - expected_code=204, - ) + response = self.delete(url, params, expected_code=204) self.assertEqual(response.status_code, 204) if delete_stock_items: if i == Target.delete_sub_locations_delete_stockitems: # Check if all sub-categories deleted - self.assertEqual(StockItem.objects.count(), stock_location_count_before) + self.assertEqual( + StockItem.objects.count(), stock_location_count_before + ) elif i == Target.move_sub_locations_to_parent_delete_stockitems: # Check if all stock locations deleted - self.assertEqual(StockItem.objects.count(), stock_location_count_before + len(child_stock_locations_items)) + self.assertEqual( + StockItem.objects.count(), + stock_location_count_before + len(child_stock_locations_items), + ) else: # Stock locations moved to the parent location for stock_item in stock_items: @@ -220,11 +278,15 @@ class StockLocationTest(StockAPITestCase): if delete_sub_locations: for child_stock_location_item in child_stock_locations_items: child_stock_location_item.refresh_from_db() - self.assertEqual(child_stock_location_item.location, parent_stock_location) + self.assertEqual( + child_stock_location_item.location, parent_stock_location + ) if delete_sub_locations: # Check if all sub-locations are deleted - self.assertEqual(StockLocation.objects.count(), stocklocation_count_before) + self.assertEqual( + StockLocation.objects.count(), stocklocation_count_before + ) else: # Check if all sub-locations moved to the parent for child in child_stock_locations: @@ -244,7 +306,7 @@ class StockLocationTest(StockAPITestCase): name='Structural stock location', description='This is the structural stock location', parent=None, - structural=True + structural=True, ) stock_item_count_before = StockItem.objects.count() @@ -253,7 +315,7 @@ class StockLocationTest(StockAPITestCase): with self.assertRaises(ValidationError): item = StockItem.objects.create( batch="Stock item which shall not be created", - location=structural_location + location=structural_location, ) # Ensure that the stock item really did not get created in the structural location @@ -264,21 +326,20 @@ class StockLocationTest(StockAPITestCase): name='Non-structural category', description='This is a non-structural category', parent=None, - structural=False + structural=False, ) # Construct a part for stock item creation part = Part.objects.create( - name='Part for stock item creation', description='Part for stock item creation', + name='Part for stock item creation', + description='Part for stock item creation', category=None, is_template=False, ) # Create the test stock item located to a non-structural category item = StockItem.objects.create( - batch="BBB", - location=non_structural_location, - part=part + batch="BBB", location=non_structural_location, part=part ) # Try to relocate it to a structural location @@ -299,42 +360,95 @@ class StockLocationTest(StockAPITestCase): """Test stock location icon inheritance from StockLocationType.""" parent_location = StockLocation.objects.create(name="Parent location") - location_type = StockLocationType.objects.create(name="Box", description="This is a very cool type of box", icon="fas fa-box") - location = StockLocation.objects.create(name="Test location", custom_icon="fas fa-microscope", location_type=location_type, parent=parent_location) + location_type = StockLocationType.objects.create( + name="Box", description="This is a very cool type of box", icon="fas fa-box" + ) + location = StockLocation.objects.create( + name="Test location", + custom_icon="fas fa-microscope", + location_type=location_type, + parent=parent_location, + ) - res = self.get(self.list_url, {"parent": str(parent_location.pk)}, expected_code=200).json() - self.assertEqual(res[0]["icon"], "fas fa-microscope", "Custom icon from location should be returned") + res = self.get( + self.list_url, {"parent": str(parent_location.pk)}, expected_code=200 + ).json() + self.assertEqual( + res[0]["icon"], + "fas fa-microscope", + "Custom icon from location should be returned", + ) location.custom_icon = "" location.save() - res = self.get(self.list_url, {"parent": str(parent_location.pk)}, expected_code=200).json() - self.assertEqual(res[0]["icon"], "fas fa-box", "Custom icon is None, therefore it should inherit the location type icon") + res = self.get( + self.list_url, {"parent": str(parent_location.pk)}, expected_code=200 + ).json() + self.assertEqual( + res[0]["icon"], + "fas fa-box", + "Custom icon is None, therefore it should inherit the location type icon", + ) location_type.icon = "" location_type.save() - res = self.get(self.list_url, {"parent": str(parent_location.pk)}, expected_code=200).json() - self.assertEqual(res[0]["icon"], "", "Custom icon and location type icon is None, None should be returned") + res = self.get( + self.list_url, {"parent": str(parent_location.pk)}, expected_code=200 + ).json() + self.assertEqual( + res[0]["icon"], + "", + "Custom icon and location type icon is None, None should be returned", + ) def test_stock_location_list_filter(self): """Test stock location list filters.""" parent_location = StockLocation.objects.create(name="Parent location") - location_type = StockLocationType.objects.create(name="Box", description="This is a very cool type of box", icon="fas fa-box") - location_type2 = StockLocationType.objects.create(name="Shelf", description="This is a very cool type of shelf", icon="fas fa-shapes") - StockLocation.objects.create(name="Test location w. type", location_type=location_type, parent=parent_location) - StockLocation.objects.create(name="Test location w. type 2", parent=parent_location, location_type=location_type2) - StockLocation.objects.create(name="Test location wo type", parent=parent_location) + location_type = StockLocationType.objects.create( + name="Box", description="This is a very cool type of box", icon="fas fa-box" + ) + location_type2 = StockLocationType.objects.create( + name="Shelf", + description="This is a very cool type of shelf", + icon="fas fa-shapes", + ) + StockLocation.objects.create( + name="Test location w. type", + location_type=location_type, + parent=parent_location, + ) + StockLocation.objects.create( + name="Test location w. type 2", + parent=parent_location, + location_type=location_type2, + ) + StockLocation.objects.create( + name="Test location wo type", parent=parent_location + ) - res = self.get(self.list_url, {"parent": str(parent_location.pk), "has_location_type": "1"}, expected_code=200).json() + res = self.get( + self.list_url, + {"parent": str(parent_location.pk), "has_location_type": "1"}, + expected_code=200, + ).json() self.assertEqual(len(res), 2) self.assertEqual(res[0]["name"], "Test location w. type") self.assertEqual(res[1]["name"], "Test location w. type 2") - res = self.get(self.list_url, {"parent": str(parent_location.pk), "location_type": str(location_type.pk)}, expected_code=200).json() + res = self.get( + self.list_url, + {"parent": str(parent_location.pk), "location_type": str(location_type.pk)}, + expected_code=200, + ).json() self.assertEqual(len(res), 1) self.assertEqual(res[0]["name"], "Test location w. type") - res = self.get(self.list_url, {"parent": str(parent_location.pk), "has_location_type": "0"}, expected_code=200).json() + res = self.get( + self.list_url, + {"parent": str(parent_location.pk), "has_location_type": "0"}, + expected_code=200, + ).json() self.assertEqual(len(res), 1) self.assertEqual(res[0]["name"], "Test location wo type") @@ -347,9 +461,15 @@ class StockLocationTypeTest(StockAPITestCase): def test_list(self): """Test that the list endpoint works as expected.""" location_types = [ - StockLocationType.objects.create(name="Type 1", description="Type 1 desc", icon="fas fa-box"), - StockLocationType.objects.create(name="Type 2", description="Type 2 desc", icon="fas fa-box"), - StockLocationType.objects.create(name="Type 3", description="Type 3 desc", icon="fas fa-box"), + StockLocationType.objects.create( + name="Type 1", description="Type 1 desc", icon="fas fa-box" + ), + StockLocationType.objects.create( + name="Type 2", description="Type 2 desc", icon="fas fa-box" + ), + StockLocationType.objects.create( + name="Type 3", description="Type 3 desc", icon="fas fa-box" + ), ] StockLocation.objects.create(name="Loc 1", location_type=location_types[0]) @@ -362,19 +482,36 @@ class StockLocationTypeTest(StockAPITestCase): def test_delete(self): """Test that we can delete a location type via API.""" - location_type = StockLocationType.objects.create(name="Type 1", description="Type 1 desc", icon="fas fa-box") - self.delete(reverse('api-location-type-detail', kwargs={"pk": location_type.pk}), expected_code=204) + location_type = StockLocationType.objects.create( + name="Type 1", description="Type 1 desc", icon="fas fa-box" + ) + self.delete( + reverse('api-location-type-detail', kwargs={"pk": location_type.pk}), + expected_code=204, + ) self.assertEqual(StockLocationType.objects.count(), 0) def test_create(self): """Test that we can create a location type via API.""" - self.post(self.list_url, {"name": "Test Type 1", "description": "Test desc 1", "icon": "fas fa-box"}, expected_code=201) - self.assertIsNotNone(StockLocationType.objects.filter(name="Test Type 1").first()) + self.post( + self.list_url, + {"name": "Test Type 1", "description": "Test desc 1", "icon": "fas fa-box"}, + expected_code=201, + ) + self.assertIsNotNone( + StockLocationType.objects.filter(name="Test Type 1").first() + ) def test_update(self): """Test that we can update a location type via API.""" - location_type = StockLocationType.objects.create(name="Type 1", description="Type 1 desc", icon="fas fa-box") - res = self.patch(reverse('api-location-type-detail', kwargs={"pk": location_type.pk}), {"icon": "fas fa-shapes"}, expected_code=200).json() + location_type = StockLocationType.objects.create( + name="Type 1", description="Type 1 desc", icon="fas fa-box" + ) + res = self.patch( + reverse('api-location-type-detail', kwargs={"pk": location_type.pk}), + {"icon": "fas fa-shapes"}, + expected_code=200, + ).json() self.assertEqual(res["icon"], "fas fa-shapes") @@ -404,14 +541,18 @@ class StockItemListTest(StockAPITestCase): self.assertEqual(len(response.data), StockItem.objects.count()) # Filter with "cascade=False" for the top-level location - response = self.get(self.list_url, {'location': 'null', 'cascade': False}, expected_code=200) + response = self.get( + self.list_url, {'location': 'null', 'cascade': False}, expected_code=200 + ) self.assertTrue(len(response.data) < StockItem.objects.count()) for result in response.data: self.assertIsNone(result['location']) # Filter with "cascade=True" - response = self.get(self.list_url, {'location': 'null', 'cascade': True}, expected_code=200) + response = self.get( + self.list_url, {'location': 'null', 'cascade': True}, expected_code=200 + ) self.assertEqual(len(response.data), StockItem.objects.count()) def test_get_stock_list(self): @@ -600,7 +741,9 @@ class StockItemListTest(StockAPITestCase): self.assertEqual(response.status_code, 200) - self.assertTrue(isinstance(response, django.http.response.StreamingHttpResponse)) + self.assertTrue( + isinstance(response, django.http.response.StreamingHttpResponse) + ) file_object = io.StringIO(response.getvalue().decode('utf-8')) @@ -629,9 +772,7 @@ class StockItemListTest(StockAPITestCase): for h in headers: self.assertIn(h, dataset.headers) - excluded_headers = [ - 'metadata', - ] + excluded_headers = ['metadata'] for h in excluded_headers: self.assertNotIn(h, dataset.headers) @@ -655,9 +796,15 @@ class StockItemListTest(StockAPITestCase): """ # Create a build order to allocate to - assembly = part.models.Part.objects.create(name='F Assembly', description='Assembly for filter test', assembly=True) - component = part.models.Part.objects.create(name='F Component', description='Component for filter test', component=True) - bom_item = part.models.BomItem.objects.create(part=assembly, sub_part=component, quantity=10) + assembly = part.models.Part.objects.create( + name='F Assembly', description='Assembly for filter test', assembly=True + ) + component = part.models.Part.objects.create( + name='F Component', description='Component for filter test', component=True + ) + bom_item = part.models.BomItem.objects.create( + part=assembly, sub_part=component, quantity=10 + ) # Create two build orders bo_1 = build.models.Build.objects.create(part=assembly, quantity=10) @@ -666,59 +813,52 @@ class StockItemListTest(StockAPITestCase): # Test that two distinct build line items are created automatically self.assertEqual(bo_1.build_lines.count(), 1) self.assertEqual(bo_2.build_lines.count(), 1) - self.assertEqual(build.models.BuildLine.objects.filter(bom_item=bom_item).count(), 2) + self.assertEqual( + build.models.BuildLine.objects.filter(bom_item=bom_item).count(), 2 + ) build_line_1 = bo_1.build_lines.first() build_line_2 = bo_2.build_lines.first() # Allocate stock location = StockLocation.objects.first() - stock_1 = StockItem.objects.create(part=component, quantity=100, location=location) - stock_2 = StockItem.objects.create(part=component, quantity=100, location=location) - stock_3 = StockItem.objects.create(part=component, quantity=100, location=location) + stock_1 = StockItem.objects.create( + part=component, quantity=100, location=location + ) + stock_2 = StockItem.objects.create( + part=component, quantity=100, location=location + ) + stock_3 = StockItem.objects.create( + part=component, quantity=100, location=location + ) # Allocate stock_1 to two build orders build.models.BuildItem.objects.create( - stock_item=stock_1, - build_line=build_line_1, - quantity=5 + stock_item=stock_1, build_line=build_line_1, quantity=5 ) build.models.BuildItem.objects.create( - stock_item=stock_1, - build_line=build_line_2, - quantity=5 + stock_item=stock_1, build_line=build_line_2, quantity=5 ) # Allocate stock_2 to 1 build orders build.models.BuildItem.objects.create( - stock_item=stock_2, - build_line=build_line_1, - quantity=5 + stock_item=stock_2, build_line=build_line_1, quantity=5 ) url = reverse('api-stock-list') # 3 items when just filtering by part response = self.get( - url, - { - "part": component.pk, - "in_stock": True - }, - expected_code=200 + url, {"part": component.pk, "in_stock": True}, expected_code=200 ) self.assertEqual(len(response.data), 3) # 1 item when filtering by "not allocated" response = self.get( url, - { - "part": component.pk, - "in_stock": True, - "allocated": False, - }, - expected_code=200 + {"part": component.pk, "in_stock": True, "allocated": False}, + expected_code=200, ) self.assertEqual(len(response.data), 1) @@ -727,12 +867,8 @@ class StockItemListTest(StockAPITestCase): # 2 items when filtering by "allocated" response = self.get( url, - { - "part": component.pk, - "in_stock": True, - "allocated": True, - }, - expected_code=200 + {"part": component.pk, "in_stock": True, "allocated": True}, + expected_code=200, ) self.assertEqual(len(response.data), 2) @@ -755,11 +891,8 @@ class StockItemListTest(StockAPITestCase): prt = Part.objects.first() StockItem.objects.bulk_create([ - StockItem( - part=prt, - quantity=1, - level=0, tree_id=0, lft=0, rght=0, - ) for _ in range(100) + StockItem(part=prt, quantity=1, level=0, tree_id=0, lft=0, rght=0) + for _ in range(100) ]) # List *all* stock items @@ -798,12 +931,7 @@ class StockItemTest(StockAPITestCase): # The part 'R_4K7_0603' (pk=4) has a default location specified response = self.post( - self.list_url, - data={ - 'part': 4, - 'quantity': 10 - }, - expected_code=201 + self.list_url, data={'part': 4, 'quantity': 10}, expected_code=201 ) self.assertEqual(response.data['location'], 2) @@ -812,12 +940,8 @@ class StockItemTest(StockAPITestCase): response = self.post( self.list_url, - data={ - 'part': 4, - 'quantity': 20, - 'location': 1, - }, - expected_code=201 + data={'part': 4, 'quantity': 20, 'location': 1}, + expected_code=201, ) self.assertEqual(response.data['location'], 1) @@ -825,12 +949,8 @@ class StockItemTest(StockAPITestCase): response = self.post( self.list_url, - data={ - 'part': 4, - 'quantity': 20, - 'location': '', - }, - expected_code=201 + data={'part': 4, 'quantity': 20, 'location': ''}, + expected_code=201, ) self.assertEqual(response.data['location'], None) @@ -839,37 +959,29 @@ class StockItemTest(StockAPITestCase): """Test creation of a StockItem via the API.""" # POST with an empty part reference - response = self.client.post( - self.list_url, - data={ - 'quantity': 10, - 'location': 1 - } - ) + response = self.client.post(self.list_url, data={'quantity': 10, 'location': 1}) - self.assertContains(response, 'Valid part must be supplied', status_code=status.HTTP_400_BAD_REQUEST) + self.assertContains( + response, + 'Valid part must be supplied', + status_code=status.HTTP_400_BAD_REQUEST, + ) # POST with an invalid part reference response = self.client.post( - self.list_url, - data={ - 'quantity': 10, - 'location': 1, - 'part': 10000000, - } + self.list_url, data={'quantity': 10, 'location': 1, 'part': 10000000} ) - self.assertContains(response, 'Valid part must be supplied', status_code=status.HTTP_400_BAD_REQUEST) + self.assertContains( + response, + 'Valid part must be supplied', + status_code=status.HTTP_400_BAD_REQUEST, + ) # POST without quantity response = self.post( - self.list_url, - { - 'part': 1, - 'location': 1, - }, - expected_code=400 + self.list_url, {'part': 1, 'location': 1}, expected_code=400 ) self.assertIn('Quantity is required', str(response.data)) @@ -877,12 +989,8 @@ class StockItemTest(StockAPITestCase): # POST with quantity and part and location response = self.post( self.list_url, - data={ - 'part': 1, - 'location': 1, - 'quantity': 10, - }, - expected_code=201 + data={'part': 1, 'location': 1, 'quantity': 10}, + expected_code=201, ) def test_stock_item_create_withsupplierpart(self): @@ -890,13 +998,8 @@ class StockItemTest(StockAPITestCase): # POST with non-existent supplier part response = self.post( self.list_url, - data={ - 'part': 1, - 'location': 1, - 'quantity': 4, - 'supplier_part': 1000991 - }, - expected_code=400 + data={'part': 1, 'location': 1, 'quantity': 4, 'supplier_part': 1000991}, + expected_code=400, ) self.assertIn('The given supplier part does not exist', str(response.data)) @@ -915,7 +1018,7 @@ class StockItemTest(StockAPITestCase): 'purchase_price': 123.45, 'purchase_price_currency': 'USD', }, - expected_code=201 + expected_code=201, ) # Reload part, count stock again @@ -940,7 +1043,7 @@ class StockItemTest(StockAPITestCase): 'purchase_price': 123.45, 'purchase_price_currency': 'USD', }, - expected_code=201 + expected_code=201, ) # Reload part, count stock again part_4 = part.models.Part.objects.get(pk=4) @@ -962,7 +1065,7 @@ class StockItemTest(StockAPITestCase): 'purchase_price': 123.45, 'purchase_price_currency': 'USD', }, - expected_code=201 + expected_code=201, ) # Reload part, count stock again part_4 = part.models.Part.objects.get(pk=4) @@ -984,7 +1087,7 @@ class StockItemTest(StockAPITestCase): 'purchase_price': 123.45, 'purchase_price_currency': 'USD', }, - expected_code=201 + expected_code=201, ) # Reload part, count stock again part_4 = part.models.Part.objects.get(pk=4) @@ -1007,21 +1110,13 @@ class StockItemTest(StockAPITestCase): # This should fail, incorrect serial number count self.post( self.list_url, - data={ - 'part': trackable_part.pk, - 'quantity': 10, - 'serial_numbers': '1-20', - }, + data={'part': trackable_part.pk, 'quantity': 10, 'serial_numbers': '1-20'}, expected_code=400, ) response = self.post( self.list_url, - data={ - 'part': trackable_part.pk, - 'quantity': 10, - 'serial_numbers': '1-10', - }, + data={'part': trackable_part.pk, 'quantity': 10, 'serial_numbers': '1-10'}, expected_code=201, ) @@ -1036,10 +1131,7 @@ class StockItemTest(StockAPITestCase): # Check the unique stock item has been created - item = StockItem.objects.get( - part=trackable_part, - serial=str(i), - ) + item = StockItem.objects.get(part=trackable_part, serial=str(i)) # Item location should have been set automatically self.assertIsNotNone(item.location) @@ -1062,10 +1154,7 @@ class StockItemTest(StockAPITestCase): - Part <25> has a default_expiry of 10 days """ # First test - create a new StockItem without an expiry date - data = { - 'part': 4, - 'quantity': 10, - } + data = {'part': 4, 'quantity': 10} response = self.post(self.list_url, data, expected_code=201) @@ -1080,10 +1169,7 @@ class StockItemTest(StockAPITestCase): self.assertEqual(response.data['expiry_date'], '2022-12-12') # Third test - create a new StockItem for a Part which has a default expiry time - data = { - 'part': 25, - 'quantity': 10 - } + data = {'part': 25, 'quantity': 10} response = self.post(self.list_url, data, expected_code=201) @@ -1109,47 +1195,25 @@ class StockItemTest(StockAPITestCase): self.assertEqual(data['purchase_price_currency'], 'AUD') # Update just the amount - data = self.patch( - url, - { - 'purchase_price': 456 - }, - expected_code=200 - ).data + data = self.patch(url, {'purchase_price': 456}, expected_code=200).data self.assertEqual(data['purchase_price'], '456.000000') self.assertEqual(data['purchase_price_currency'], 'AUD') # Update the currency data = self.patch( - url, - { - 'purchase_price_currency': 'NZD', - }, - expected_code=200 + url, {'purchase_price_currency': 'NZD'}, expected_code=200 ).data self.assertEqual(data['purchase_price_currency'], 'NZD') # Clear the price field - data = self.patch( - url, - { - 'purchase_price': None, - }, - expected_code=200 - ).data + data = self.patch(url, {'purchase_price': None}, expected_code=200).data self.assertEqual(data['purchase_price'], None) # Invalid currency code - data = self.patch( - url, - { - 'purchase_price_currency': 'xyz', - }, - expected_code=400 - ) + data = self.patch(url, {'purchase_price_currency': 'xyz'}, expected_code=400) data = self.get(url).data self.assertEqual(data['purchase_price_currency'], 'NZD') @@ -1160,17 +1224,11 @@ class StockItemTest(StockAPITestCase): parent_part = part.models.Part.objects.get(pk=100) item = StockItem.objects.create( - part=parent_part, - serial='12345688-1230', - quantity=1, + part=parent_part, serial='12345688-1230', quantity=1 ) sub_part = part.models.Part.objects.get(pk=50) - sub_item = StockItem.objects.create( - part=sub_part, - serial='xyz-123', - quantity=1, - ) + sub_item = StockItem.objects.create(part=sub_part, serial='xyz-123', quantity=1) n_entries = sub_item.tracking_info.count() @@ -1185,18 +1243,17 @@ class StockItemTest(StockAPITestCase): 'stock_item': 520, 'note': 'This should fail, as Item #522 is not in the BOM', }, - expected_code=400 + expected_code=400, ) - self.assertIn('Selected part is not in the Bill of Materials', str(response.data)) + self.assertIn( + 'Selected part is not in the Bill of Materials', str(response.data) + ) # Now, try to install an item which *is* in the BOM for the parent part response = self.post( url, - { - 'stock_item': sub_item.pk, - 'note': "This time, it should be good!", - }, + {'stock_item': sub_item.pk, 'note': "This time, it should be good!"}, expected_code=201, ) @@ -1209,10 +1266,7 @@ class StockItemTest(StockAPITestCase): # Try to install again - this time, should fail because the StockItem is not available! response = self.post( url, - { - 'stock_item': sub_item.pk, - 'note': 'Expectation: failure!', - }, + {'stock_item': sub_item.pk, 'note': 'Expectation: failure!'}, expected_code=400, ) @@ -1222,13 +1276,7 @@ class StockItemTest(StockAPITestCase): url = reverse('api-stock-item-uninstall', kwargs={'pk': sub_item.pk}) - self.post( - url, - { - 'location': 1, - }, - expected_code=201, - ) + self.post(url, {'location': 1}, expected_code=201) sub_item.refresh_from_db() @@ -1249,19 +1297,13 @@ class StockItemTest(StockAPITestCase): url = reverse('api-stock-item-return', kwargs={'pk': item.pk}) # Empty POST will fail - response = self.post( - url, {}, - expected_code=400 - ) + response = self.post(url, {}, expected_code=400) self.assertIn('This field is required', str(response.data['location'])) response = self.post( url, - { - 'location': '1', - 'notes': 'Returned from this customer for testing', - }, + {'location': '1', 'notes': 'Returned from this customer for testing'}, expected_code=201, ) @@ -1279,7 +1321,8 @@ class StockItemTest(StockAPITestCase): # First, construct a set of template / variant parts master_part = part.models.Part.objects.create( - name='Master', description='Master part which has variants', + name='Master', + description='Master part which has variants', category=category, is_template=True, ) @@ -1288,49 +1331,31 @@ class StockItemTest(StockAPITestCase): # Construct a set of variant parts for color in ['Red', 'Green', 'Blue', 'Yellow', 'Pink', 'Black']: - variants.append(part.models.Part.objects.create( - name=f"{color} Variant", description="Variant part with a specific color", - variant_of=master_part, - category=category, - )) + variants.append( + part.models.Part.objects.create( + name=f"{color} Variant", + description="Variant part with a specific color", + variant_of=master_part, + category=category, + ) + ) - stock_item = StockItem.objects.create( - part=master_part, - quantity=1000, - ) + stock_item = StockItem.objects.create(part=master_part, quantity=1000) url = reverse('api-stock-item-convert', kwargs={'pk': stock_item.pk}) # Attempt to convert to a part which does not exist - response = self.post( - url, - { - 'part': 999999, - }, - expected_code=400, - ) + response = self.post(url, {'part': 999999}, expected_code=400) self.assertIn('object does not exist', str(response.data['part'])) # Attempt to convert to a part which is not a valid option - response = self.post( - url, - { - 'part': 1, - }, - expected_code=400 - ) + response = self.post(url, {'part': 1}, expected_code=400) self.assertIn('Selected part is not a valid option', str(response.data['part'])) for variant in variants: - response = self.post( - url, - { - 'part': variant.pk, - }, - expected_code=201, - ) + response = self.post(url, {'part': variant.pk}, expected_code=201) stock_item.refresh_from_db() self.assertEqual(stock_item.part, variant) @@ -1342,9 +1367,7 @@ class StockItemTest(StockAPITestCase): prt = Part.objects.first() # Create a bunch of items - items = [ - StockItem.objects.create(part=prt, quantity=10) for _ in range(10) - ] + items = [StockItem.objects.create(part=prt, quantity=10) for _ in range(10)] for item in items: item.refresh_from_db() @@ -1386,7 +1409,6 @@ class StocktakeTest(StockAPITestCase): def test_action(self): """Test each stocktake action endpoint, for validation.""" for endpoint in ['api-stock-count', 'api-stock-add', 'api-stock-remove']: - url = reverse(endpoint) data = {} @@ -1396,9 +1418,7 @@ class StocktakeTest(StockAPITestCase): self.assertIn("This field is required", str(response.data["items"])) - data['items'] = [{ - 'no': 'aa' - }] + data['items'] = [{'no': 'aa'}] # POST without a PK response = self.post(url, data, expected_code=400) @@ -1406,50 +1426,51 @@ class StocktakeTest(StockAPITestCase): self.assertIn('This field is required', str(response.data)) # POST with an invalid PK - data['items'] = [{ - 'pk': 10 - }] + data['items'] = [{'pk': 10}] response = self.post(url, data, expected_code=400) - self.assertContains(response, 'object does not exist', status_code=status.HTTP_400_BAD_REQUEST) + self.assertContains( + response, + 'object does not exist', + status_code=status.HTTP_400_BAD_REQUEST, + ) # POST with missing quantity value - data['items'] = [{ - 'pk': 1234 - }] + data['items'] = [{'pk': 1234}] response = self.post(url, data, expected_code=400) - self.assertContains(response, 'This field is required', status_code=status.HTTP_400_BAD_REQUEST) + self.assertContains( + response, + 'This field is required', + status_code=status.HTTP_400_BAD_REQUEST, + ) # POST with an invalid quantity value - data['items'] = [{ - 'pk': 1234, - 'quantity': '10x0d' - }] + data['items'] = [{'pk': 1234, 'quantity': '10x0d'}] response = self.post(url, data) - self.assertContains(response, 'A valid number is required', status_code=status.HTTP_400_BAD_REQUEST) + self.assertContains( + response, + 'A valid number is required', + status_code=status.HTTP_400_BAD_REQUEST, + ) - data['items'] = [{ - 'pk': 1234, - 'quantity': "-1.234" - }] + data['items'] = [{'pk': 1234, 'quantity': "-1.234"}] response = self.post(url, data) - self.assertContains(response, 'Ensure this value is greater than or equal to 0', status_code=status.HTTP_400_BAD_REQUEST) + self.assertContains( + response, + 'Ensure this value is greater than or equal to 0', + status_code=status.HTTP_400_BAD_REQUEST, + ) def test_transfer(self): """Test stock transfers.""" data = { - 'items': [ - { - 'pk': 1234, - 'quantity': 10, - } - ], + 'items': [{'pk': 1234, 'quantity': 10}], 'location': 1, - 'notes': "Moving to a new location" + 'notes': "Moving to a new location", } url = reverse('api-stock-transfer') @@ -1462,7 +1483,11 @@ class StocktakeTest(StockAPITestCase): response = self.post(url, data, expected_code=400) - self.assertContains(response, 'Incorrect type. Expected pk value', status_code=status.HTTP_400_BAD_REQUEST) + self.assertContains( + response, + 'Incorrect type. Expected pk value', + status_code=status.HTTP_400_BAD_REQUEST, + ) class StockItemDeletionTest(StockAPITestCase): @@ -1474,16 +1499,11 @@ class StockItemDeletionTest(StockAPITestCase): # Create and then delete a bunch of stock items for idx in range(10): - # Create new StockItem via the API response = self.post( reverse('api-stock-list'), - { - 'part': 1, - 'location': 1, - 'quantity': idx, - }, - expected_code=201 + {'part': 1, 'location': 1, 'quantity': idx}, + expected_code=201, ) pk = response.data['pk'] @@ -1492,8 +1512,7 @@ class StockItemDeletionTest(StockAPITestCase): # Request deletion via the API self.delete( - reverse('api-stock-detail', kwargs={'pk': pk}), - expected_code=204 + reverse('api-stock-detail', kwargs={'pk': pk}), expected_code=204 ) self.assertEqual(StockItem.objects.count(), n) @@ -1524,24 +1543,13 @@ class StockTestResultTest(StockAPITestCase): url = self.get_url() - self.post( - url, - data={ - 'test': 'A test', - 'result': True, - }, - expected_code=400 - ) + self.post(url, data={'test': 'A test', 'result': True}, expected_code=400) # This one should pass! self.post( url, - data={ - 'test': 'A test', - 'stock_item': 105, - 'result': True, - }, - expected_code=201 + data={'test': 'A test', 'stock_item': 105, 'result': True}, + expected_code=201, ) def test_post(self): @@ -1588,7 +1596,6 @@ class StockTestResultTest(StockAPITestCase): image_file = os.path.join(here, 'fixtures', 'test_image.bmp') with open(image_file, 'rb') as bitmap: - data = { 'stock_item': 105, 'test': 'Checked Steam Valve', @@ -1620,9 +1627,9 @@ class StockTestResultTest(StockAPITestCase): 'stock_item': 1, 'test': f"Some test {_ii}", 'result': True, - 'value': 'Test result value' + 'value': 'Test result value', }, - expected_code=201 + expected_code=201, ) tests.append(response.data['pk']) @@ -1630,37 +1637,19 @@ class StockTestResultTest(StockAPITestCase): self.assertEqual(StockItemTestResult.objects.count(), n + 50) # Attempt a delete without providing items - self.delete( - url, - {}, - expected_code=400, - ) + self.delete(url, {}, expected_code=400) # Now, let's delete all the newly created items with a single API request # However, we will provide incorrect filters response = self.delete( - url, - { - 'items': tests, - 'filters': { - 'stock_item': 10, - } - }, - expected_code=204 + url, {'items': tests, 'filters': {'stock_item': 10}}, expected_code=204 ) self.assertEqual(StockItemTestResult.objects.count(), n + 50) # Try again, but with the correct filters this time response = self.delete( - url, - { - 'items': tests, - 'filters': { - 'stock_item': 1, - } - }, - expected_code=204 + url, {'items': tests, 'filters': {'stock_item': 1}}, expected_code=204 ) self.assertEqual(StockItemTestResult.objects.count(), n) @@ -1674,45 +1663,24 @@ class StockAssignTest(StockAPITestCase): def test_invalid(self): """Test invalid assign.""" # Test with empty data - response = self.post( - self.URL, - data={}, - expected_code=400, - ) + response = self.post(self.URL, data={}, expected_code=400) self.assertIn('This field is required', str(response.data['items'])) self.assertIn('This field is required', str(response.data['customer'])) # Test with an invalid customer - response = self.post( - self.URL, - data={ - 'customer': 999, - }, - expected_code=400, - ) + response = self.post(self.URL, data={'customer': 999}, expected_code=400) self.assertIn('object does not exist', str(response.data['customer'])) # Test with a company which is *not* a customer - response = self.post( - self.URL, - data={ - 'customer': 3, - }, - expected_code=400, - ) + response = self.post(self.URL, data={'customer': 3}, expected_code=400) self.assertIn('company is not a customer', str(response.data['customer'])) # Test with an empty items list response = self.post( - self.URL, - data={ - 'items': [], - 'customer': 4, - }, - expected_code=400, + self.URL, data={'items': [], 'customer': 4}, expected_code=400 ) self.assertIn('A list of stock items must be provided', str(response.data)) @@ -1725,14 +1693,7 @@ class StockAssignTest(StockAPITestCase): response = self.post( self.URL, - data={ - 'items': [ - { - 'item': stock_item.pk, - }, - ], - 'customer': 4, - }, + data={'items': [{'item': stock_item.pk}], 'customer': 4}, expected_code=400, ) @@ -1743,27 +1704,18 @@ class StockAssignTest(StockAPITestCase): stock_items = [] for i in range(5): - stock_item = StockItem.objects.create( - part=part.models.Part.objects.get(pk=25), - quantity=i + 5, + part=part.models.Part.objects.get(pk=25), quantity=i + 5 ) - stock_items.append({ - 'item': stock_item.pk - }) + stock_items.append({'item': stock_item.pk}) customer = company.models.Company.objects.get(pk=4) self.assertEqual(customer.assigned_stock.count(), 0) response = self.post( - self.URL, - data={ - 'items': stock_items, - 'customer': 4, - }, - expected_code=201, + self.URL, data={'items': stock_items, 'customer': 4}, expected_code=201 ) self.assertEqual(response.data['customer'], 4) @@ -1788,45 +1740,28 @@ class StockMergeTest(StockAPITestCase): cls.sp_2 = company.models.SupplierPart.objects.get(pk=101) cls.item_1 = StockItem.objects.create( - part=cls.part, - supplier_part=cls.sp_1, - quantity=100, + part=cls.part, supplier_part=cls.sp_1, quantity=100 ) cls.item_2 = StockItem.objects.create( - part=cls.part, - supplier_part=cls.sp_2, - quantity=100, + part=cls.part, supplier_part=cls.sp_2, quantity=100 ) cls.item_3 = StockItem.objects.create( - part=cls.part, - supplier_part=cls.sp_2, - quantity=50, + part=cls.part, supplier_part=cls.sp_2, quantity=50 ) def test_missing_data(self): """Test responses which are missing required data.""" # Post completely empty - data = self.post( - self.URL, - {}, - expected_code=400 - ).data + data = self.post(self.URL, {}, expected_code=400).data self.assertIn('This field is required', str(data['items'])) self.assertIn('This field is required', str(data['location'])) # Post with a location and empty items list - data = self.post( - self.URL, - { - 'items': [], - 'location': 1, - }, - expected_code=400 - ).data + data = self.post(self.URL, {'items': [], 'location': 1}, expected_code=400).data self.assertIn('At least two stock items', str(data)) @@ -1835,17 +1770,7 @@ class StockMergeTest(StockAPITestCase): # Serialized stock items should be rejected data = self.post( self.URL, - { - 'items': [ - { - 'item': 501, - }, - { - 'item': 502, - } - ], - 'location': 1, - }, + {'items': [{'item': 501}, {'item': 502}], 'location': 1}, expected_code=400, ).data @@ -1855,17 +1780,7 @@ class StockMergeTest(StockAPITestCase): data = self.post( self.URL, - { - 'items': [ - { - 'item': 11, - }, - { - 'item': 11, - } - ], - 'location': 1, - }, + {'items': [{'item': 11}, {'item': 11}], 'location': 1}, expected_code=400, ).data @@ -1874,17 +1789,7 @@ class StockMergeTest(StockAPITestCase): # Check for mismatching stock items data = self.post( self.URL, - { - 'items': [ - { - 'item': 1234, - }, - { - 'item': 11, - } - ], - 'location': 1, - }, + {'items': [{'item': 1234}, {'item': 11}], 'location': 1}, expected_code=400, ).data @@ -1892,22 +1797,11 @@ class StockMergeTest(StockAPITestCase): # Check for mismatching supplier parts payload = { - 'items': [ - { - 'item': self.item_1.pk, - }, - { - 'item': self.item_2.pk, - }, - ], + 'items': [{'item': self.item_1.pk}, {'item': self.item_2.pk}], 'location': 1, } - data = self.post( - self.URL, - payload, - expected_code=400, - ).data + data = self.post(self.URL, payload, expected_code=400).data self.assertIn('Stock items must refer to the same supplier part', str(data)) @@ -1919,25 +1813,15 @@ class StockMergeTest(StockAPITestCase): payload = { 'items': [ - { - 'item': self.item_1.pk, - }, - { - 'item': self.item_2.pk, - }, - { - 'item': self.item_3.pk, - }, + {'item': self.item_1.pk}, + {'item': self.item_2.pk}, + {'item': self.item_3.pk}, ], 'location': 1, 'allow_mismatched_suppliers': True, } - self.post( - self.URL, - payload, - expected_code=201, - ) + self.post(self.URL, payload, expected_code=201) self.item_1.refresh_from_db() @@ -1962,10 +1846,7 @@ class StockMetadataAPITest(InvenTreeAPITestCase): 'stock_tests', ] - roles = [ - 'stock.change', - 'stock_location.change', - ] + roles = ['stock.change', 'stock_location.change'] def metatester(self, apikey, model): """Generic tester""" @@ -1983,17 +1864,15 @@ class StockMetadataAPITest(InvenTreeAPITestCase): self.patch( url, - { - 'metadata': { - f'abc-{numstr}': f'xyz-{apikey}-{numstr}', - } - }, - expected_code=200 + {'metadata': {f'abc-{numstr}': f'xyz-{apikey}-{numstr}'}}, + expected_code=200, ) # Refresh modeldata.refresh_from_db() - self.assertEqual(modeldata.get_metadata(f'abc-{numstr}'), f'xyz-{apikey}-{numstr}') + self.assertEqual( + modeldata.get_metadata(f'abc-{numstr}'), f'xyz-{apikey}-{numstr}' + ) def test_metadata(self): """Test all endpoints""" diff --git a/InvenTree/stock/test_migrations.py b/InvenTree/stock/test_migrations.py index e153344e8f..db7cf26670 100644 --- a/InvenTree/stock/test_migrations.py +++ b/InvenTree/stock/test_migrations.py @@ -24,18 +24,14 @@ class TestSerialNumberMigration(MigratorTestCase): trackable=True, level=0, tree_id=0, - lft=0, rght=0 + lft=0, + rght=0, ) # Create some serialized stock items for sn in range(10, 20): StockItem.objects.create( - part=my_part, - quantity=1, - serial=sn, - level=0, - tree_id=0, - lft=0, rght=0 + part=my_part, quantity=1, serial=sn, level=0, tree_id=0, lft=0, rght=0 ) # Create a stock item with a very large serial number @@ -45,7 +41,8 @@ class TestSerialNumberMigration(MigratorTestCase): serial='9999999999999999999999999999999999999999999999999999999999999', level=0, tree_id=0, - lft=0, rght=0 + lft=0, + rght=0, ) self.big_ref_pk = item.pk @@ -63,8 +60,11 @@ class TestSerialNumberMigration(MigratorTestCase): big_ref_item = StockItem.objects.get(pk=self.big_ref_pk) # Check that the StockItem maximum serial number - self.assertEqual(big_ref_item.serial, '9999999999999999999999999999999999999999999999999999999999999') - self.assertEqual(big_ref_item.serial_int, 0x7fffffff) + self.assertEqual( + big_ref_item.serial, + '9999999999999999999999999999999999999999999999999999999999999', + ) + self.assertEqual(big_ref_item.serial_int, 0x7FFFFFFF) class TestScheduledForDeletionMigration(MigratorTestCase): @@ -83,17 +83,21 @@ class TestScheduledForDeletionMigration(MigratorTestCase): name=f'Part_{idx}', description='Just a part, nothing to see here', active=True, - level=0, tree_id=0, - lft=0, rght=0, + level=0, + tree_id=0, + lft=0, + rght=0, ) for jj in range(5): StockItem.objects.create( part=part, quantity=jj + 5, - level=0, tree_id=0, - lft=0, rght=0, - scheduled_for_deletion=True + level=0, + tree_id=0, + lft=0, + rght=0, + scheduled_for_deletion=True, ) # For extra points, create some parent-child relationships between stock items @@ -102,8 +106,10 @@ class TestScheduledForDeletionMigration(MigratorTestCase): item_1 = StockItem.objects.create( part=part, quantity=100, - level=0, tree_id=0, - lft=0, rght=0, + level=0, + tree_id=0, + lft=0, + rght=0, scheduled_for_deletion=True, ) @@ -111,8 +117,10 @@ class TestScheduledForDeletionMigration(MigratorTestCase): StockItem.objects.create( part=part, quantity=200, - level=0, tree_id=0, - lft=0, rght=0, + level=0, + tree_id=0, + lft=0, + rght=0, scheduled_for_deletion=False, parent=item_1, ) diff --git a/InvenTree/stock/test_views.py b/InvenTree/stock/test_views.py index 38a7afee01..c507b0558f 100644 --- a/InvenTree/stock/test_views.py +++ b/InvenTree/stock/test_views.py @@ -13,14 +13,7 @@ from users.models import Owner class StockViewTestCase(InvenTreeTestCase): """Mixin for Stockview tests.""" - fixtures = [ - 'category', - 'part', - 'company', - 'location', - 'supplier_part', - 'stock', - ] + fixtures = ['category', 'part', 'company', 'location', 'supplier_part', 'stock'] roles = 'all' @@ -59,13 +52,13 @@ class StockDetailTest(StockViewTestCase): # Actions to check actions = [ - "id=\\\'stock-count\\\' title=\\\'Count stock\\\'", - "id=\\\'stock-add\\\' title=\\\'Add stock\\\'", - "id=\\\'stock-remove\\\' title=\\\'Remove stock\\\'", - "id=\\\'stock-move\\\' title=\\\'Transfer stock\\\'", - "id=\\\'stock-duplicate\\\'", - "id=\\\'stock-edit\\\'", - "id=\\\'stock-delete\\\'", + "id=\\'stock-count\\' title=\\'Count stock\\'", + "id=\\'stock-add\\' title=\\'Add stock\\'", + "id=\\'stock-remove\\' title=\\'Remove stock\\'", + "id=\\'stock-move\\' title=\\'Transfer stock\\'", + "id=\\'stock-duplicate\\'", + "id=\\'stock-edit\\'", + "id=\\'stock-delete\\'", ] # Initially we should not have any of the required permissions @@ -86,6 +79,7 @@ class StockDetailTest(StockViewTestCase): class StockOwnershipTest(StockViewTestCase): """Tests for stock ownership views.""" + test_item_id = 11 test_location_id = 1 @@ -135,9 +129,13 @@ class StockOwnershipTest(StockViewTestCase): location = StockLocation.objects.get(pk=self.test_location_id) # Check that user is not allowed to change item - self.assertTrue(item.check_ownership(self.user)) # No owner -> True - self.assertTrue(location.check_ownership(self.user)) # No owner -> True - self.assertContains(self.assert_api_change(), 'You do not have permission to perform this action.', status_code=403) + self.assertTrue(item.check_ownership(self.user)) # No owner -> True + self.assertTrue(location.check_ownership(self.user)) # No owner -> True + self.assertContains( + self.assert_api_change(), + 'You do not have permission to perform this action.', + status_code=403, + ) # Adjust group rules group = Group.objects.get(name='my_test_group') @@ -153,9 +151,13 @@ class StockOwnershipTest(StockViewTestCase): location.save() # Check that user is allowed to change item - self.assertTrue(item.check_ownership(self.user)) # Owner is group -> True - self.assertTrue(location.check_ownership(self.user)) # Owner is group -> True - self.assertContains(self.assert_api_change(), f'"status":{StockStatus.DAMAGED.value}', status_code=200) + self.assertTrue(item.check_ownership(self.user)) # Owner is group -> True + self.assertTrue(location.check_ownership(self.user)) # Owner is group -> True + self.assertContains( + self.assert_api_change(), + f'"status":{StockStatus.DAMAGED.value}', + status_code=200, + ) # Change group new_group = Group.objects.create(name='new_group') @@ -166,5 +168,9 @@ class StockOwnershipTest(StockViewTestCase): location.save() # Check that user is not allowed to change item - self.assertFalse(item.check_ownership(self.user)) # Owner is not in group -> False - self.assertFalse(location.check_ownership(self.user)) # Owner is not in group -> False + self.assertFalse( + item.check_ownership(self.user) + ) # Owner is not in group -> False + self.assertFalse( + location.check_ownership(self.user) + ) # Owner is not in group -> False diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index 6843ee8bce..b88ec9679d 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -14,8 +14,7 @@ from InvenTree.unit_test import InvenTreeTestCase from order.models import SalesOrder from part.models import Part -from .models import (StockItem, StockItemTestResult, StockItemTracking, - StockLocation) +from .models import StockItem, StockItemTestResult, StockItemTracking, StockLocation class StockTestBase(InvenTreeTestCase): @@ -133,11 +132,7 @@ class StockTest(StockTestBase): item = StockItem.objects.get(pk=1) # Check that invalid URLs fail - for bad_url in [ - 'test.com', - 'httpx://abc.xyz', - 'https:google.com', - ]: + for bad_url in ['test.com', 'httpx://abc.xyz', 'https:google.com']: with self.assertRaises(ValidationError): item.link = bad_url item.save() @@ -179,41 +174,31 @@ class StockTest(StockTestBase): # Ensure that 'global uniqueness' setting is enabled InvenTreeSetting.set_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', True, self.user) - part_a = Part.objects.create(name='A', description='A part with a description', trackable=True) - part_b = Part.objects.create(name='B', description='B part with a description', trackable=True) + part_a = Part.objects.create( + name='A', description='A part with a description', trackable=True + ) + part_b = Part.objects.create( + name='B', description='B part with a description', trackable=True + ) # Create a StockItem for part_a - StockItem.objects.create( - part=part_a, - quantity=1, - serial='ABCDE', - ) + StockItem.objects.create(part=part_a, quantity=1, serial='ABCDE') # Create a StockItem for part_a (but, will error due to identical serial) with self.assertRaises(ValidationError): - StockItem.objects.create( - part=part_b, - quantity=1, - serial='ABCDE', - ) + StockItem.objects.create(part=part_b, quantity=1, serial='ABCDE') # Now, allow serial numbers to be duplicated between different parts InvenTreeSetting.set_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False, self.user) - StockItem.objects.create( - part=part_b, - quantity=1, - serial='ABCDE', - ) + StockItem.objects.create(part=part_b, quantity=1, serial='ABCDE') def test_expiry(self): """Test expiry date functionality for StockItem model.""" today = datetime.datetime.now().date() item = StockItem.objects.create( - location=self.office, - part=Part.objects.get(pk=1), - quantity=10, + location=self.office, part=Part.objects.get(pk=1), quantity=10 ) # Without an expiry_date set, item should not be "expired" @@ -249,13 +234,14 @@ class StockTest(StockTestBase): # And there should be *no* items being build self.assertEqual(part.quantity_being_built, 0) - build = Build.objects.create(reference='BO-4444', part=part, title='A test build', quantity=1) + build = Build.objects.create( + reference='BO-4444', part=part, title='A test build', quantity=1 + ) # Add some stock items which are "building" for _ in range(10): StockItem.objects.create( - part=part, build=build, - quantity=10, is_building=True + part=part, build=build, quantity=10, is_building=True ) # The "is_building" quantity should not be counted here @@ -330,7 +316,10 @@ class StockTest(StockTestBase): # There should be 16 widgets "in stock" self.assertEqual( - StockItem.objects.filter(part=25).aggregate(Sum('quantity'))['quantity__sum'], 16 + StockItem.objects.filter(part=25).aggregate(Sum('quantity'))[ + 'quantity__sum' + ], + 16, ) def test_delete_location(self): @@ -339,7 +328,9 @@ class StockTest(StockTestBase): n_stock = StockItem.objects.count() # What parts are in drawer 3? - stock_ids = [part.id for part in StockItem.objects.filter(location=self.drawer3.id)] + stock_ids = [ + part.id for part in StockItem.objects.filter(location=self.drawer3.id) + ] # Delete location - parts should move to parent location self.drawer3.delete() @@ -361,7 +352,9 @@ class StockTest(StockTestBase): self.assertEqual(it.location, self.bathroom) # There now should be 2 lots of screws in the bathroom - self.assertEqual(StockItem.objects.filter(part=1, location=self.bathroom).count(), 2) + self.assertEqual( + StockItem.objects.filter(part=1, location=self.bathroom).count(), 2 + ) # Check that a tracking item was added track = StockItemTracking.objects.filter(item=it).latest('id') @@ -469,7 +462,9 @@ class StockTest(StockTestBase): an = n - 10 customer = Company.objects.create(name="MyTestCompany") order = SalesOrder.objects.create(description="Test order") - ait = it.allocateToCustomer(customer, quantity=an, order=order, user=None, notes='Allocated some stock') + ait = it.allocateToCustomer( + customer, quantity=an, order=order, user=None, notes='Allocated some stock' + ) # Check if new stockitem is created self.assertTrue(ait) @@ -485,7 +480,9 @@ class StockTest(StockTestBase): # Check that a tracking item was added track = StockItemTracking.objects.filter(item=ait).latest('id') - self.assertEqual(track.tracking_type, StockHistoryCode.SHIPPED_AGAINST_SALES_ORDER) + self.assertEqual( + track.tracking_type, StockHistoryCode.SHIPPED_AGAINST_SALES_ORDER + ) self.assertIn('Allocated some stock', track.notes) def test_return_from_customer(self): @@ -493,21 +490,29 @@ class StockTest(StockTestBase): it = StockItem.objects.get(pk=2) # First establish total stock for this part - allstock_before = StockItem.objects.filter(part=it.part).aggregate(Sum("quantity"))["quantity__sum"] + allstock_before = StockItem.objects.filter(part=it.part).aggregate( + Sum("quantity") + )["quantity__sum"] n = it.quantity an = n - 10 customer = Company.objects.create(name="MyTestCompany") order = SalesOrder.objects.create(description="Test order") - ait = it.allocateToCustomer(customer, quantity=an, order=order, user=None, notes='Allocated some stock') + ait = it.allocateToCustomer( + customer, quantity=an, order=order, user=None, notes='Allocated some stock' + ) ait.return_from_customer(it.location, None, notes="Stock removed from customer") # When returned stock is returned to its original (parent) location, check that the parent has correct quantity self.assertEqual(it.quantity, n) - ait = it.allocateToCustomer(customer, quantity=an, order=order, user=None, notes='Allocated some stock') - ait.return_from_customer(self.drawer3, None, notes="Stock removed from customer") + ait = it.allocateToCustomer( + customer, quantity=an, order=order, user=None, notes='Allocated some stock' + ) + ait.return_from_customer( + self.drawer3, None, notes="Stock removed from customer" + ) # Check correct assignment of the new location self.assertEqual(ait.location, self.drawer3) @@ -527,7 +532,9 @@ class StockTest(StockTestBase): self.assertIn('Stock removed from customer', track.notes) # Establish total stock for the part after remove from customer to check that we still have the correct quantity in stock - allstock_after = StockItem.objects.filter(part=it.part).aggregate(Sum("quantity"))["quantity__sum"] + allstock_after = StockItem.objects.filter(part=it.part).aggregate( + Sum("quantity") + )["quantity__sum"] self.assertEqual(allstock_before, allstock_after) def test_take_stock(self): @@ -578,10 +585,7 @@ class StockTest(StockTestBase): # Ensure we do not have unique serials enabled InvenTreeSetting.set_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False, None) - item = StockItem.objects.create( - part=p, - quantity=1, - ) + item = StockItem.objects.create(part=p, quantity=1) self.assertFalse(item.serialized) @@ -609,10 +613,7 @@ class StockTest(StockTestBase): trackable=True, ) - item = StockItem.objects.create( - part=p, - quantity=1, - ) + item = StockItem.objects.create(part=p, quantity=1) for sn in [12345, '12345', ' 12345 ']: item.serial = sn @@ -631,7 +632,7 @@ class StockTest(StockTestBase): item.save() # The 'integer' portion has been clipped to a maximum value - self.assertEqual(item.serial_int, 0x7fffffff) + self.assertEqual(item.serial_int, 0x7FFFFFFF) # Non-numeric values should encode to zero for sn in ['apple', 'banana', 'carrot']: @@ -644,30 +645,18 @@ class StockTest(StockTestBase): item.serial = 100 item.save() - item_next = StockItem.objects.create( - part=p, - serial=150, - quantity=1 - ) + item_next = StockItem.objects.create(part=p, serial=150, quantity=1) self.assertEqual(item.get_next_serialized_item(), item_next) - item_prev = StockItem.objects.create( - part=p, - serial=' 57', - quantity=1, - ) + item_prev = StockItem.objects.create(part=p, serial=' 57', quantity=1) self.assertEqual(item.get_next_serialized_item(reverse=True), item_prev) # Create a number of serialized stock items around the current item for i in range(75, 125): try: - StockItem.objects.create( - part=p, - serial=i, - quantity=1, - ) + StockItem.objects.create(part=p, serial=i, quantity=1) except Exception: pass @@ -755,55 +744,25 @@ class StockTest(StockTestBase): """ # First, we will create a stock location structure - A = StockLocation.objects.create( - name='A', - description='Top level location' - ) + A = StockLocation.objects.create(name='A', description='Top level location') - B1 = StockLocation.objects.create( - name='B1', - parent=A - ) + B1 = StockLocation.objects.create(name='B1', parent=A) - B2 = StockLocation.objects.create( - name='B2', - parent=A - ) + B2 = StockLocation.objects.create(name='B2', parent=A) - B3 = StockLocation.objects.create( - name='B3', - parent=A - ) + B3 = StockLocation.objects.create(name='B3', parent=A) - C11 = StockLocation.objects.create( - name='C11', - parent=B1, - ) + C11 = StockLocation.objects.create(name='C11', parent=B1) - C12 = StockLocation.objects.create( - name='C12', - parent=B1, - ) + C12 = StockLocation.objects.create(name='C12', parent=B1) - C21 = StockLocation.objects.create( - name='C21', - parent=B2, - ) + C21 = StockLocation.objects.create(name='C21', parent=B2) - C22 = StockLocation.objects.create( - name='C22', - parent=B2, - ) + C22 = StockLocation.objects.create(name='C22', parent=B2) - C31 = StockLocation.objects.create( - name='C31', - parent=B3, - ) + C31 = StockLocation.objects.create(name='C31', parent=B3) - C32 = StockLocation.objects.create( - name='C32', - parent=B3 - ) + C32 = StockLocation.objects.create(name='C32', parent=B3) # Check that the tree_id is correct for each sublocation for loc in [B1, B2, B3, C11, C12, C21, C22, C31, C32]: @@ -850,9 +809,7 @@ class StockTest(StockTestBase): # Add some stock items to B3 for _ in range(10): StockItem.objects.create( - part=Part.objects.get(pk=1), - quantity=10, - location=B3 + part=Part.objects.get(pk=1), quantity=10, location=B3 ) self.assertEqual(StockItem.objects.filter(location=B3).count(), 10) @@ -982,7 +939,10 @@ class VariantTest(StockTestBase): chair = Part.objects.get(pk=10000) # Operations on the top-level object - [self.assertFalse(chair.validate_serial_number(i)) for i in [1, 2, 3, 4, 5, 20, 21, 22]] + [ + self.assertFalse(chair.validate_serial_number(i)) + for i in [1, 2, 3, 4, 5, 20, 21, 22] + ] self.assertFalse(chair.validate_serial_number(20)) self.assertFalse(chair.validate_serial_number(21)) @@ -1006,11 +966,7 @@ class VariantTest(StockTestBase): # Create a new serial number n = variant.get_latest_serial_number() - item = StockItem( - part=variant, - quantity=1, - serial=n - ) + item = StockItem(part=variant, quantity=1, serial=n) # This should fail with self.assertRaises(ValidationError): @@ -1074,9 +1030,7 @@ class TestResultTest(StockTestBase): test.save() StockItemTestResult.objects.create( - stock_item=item, - test='sew cushion', - result=True + stock_item=item, test='sew cushion', result=True ) # Still should be failing at this point, @@ -1088,7 +1042,7 @@ class TestResultTest(StockTestBase): stock_item=item, test='apply paint', date=datetime.datetime(2022, 12, 12), - result=True + result=True, ) self.assertTrue(item.passedAllRequiredTests()) @@ -1113,22 +1067,15 @@ class TestResultTest(StockTestBase): # Do some tests! StockItemTestResult.objects.create( - stock_item=item, - test="Firmware", - result=True + stock_item=item, test="Firmware", result=True ) StockItemTestResult.objects.create( - stock_item=item, - test="Paint Color", - result=True, - value="Red" + stock_item=item, test="Paint Color", result=True, value="Red" ) StockItemTestResult.objects.create( - stock_item=item, - test="Applied Sticker", - result=False + stock_item=item, test="Applied Sticker", result=False ) self.assertEqual(item.test_results.count(), 3) @@ -1142,10 +1089,7 @@ class TestResultTest(StockTestBase): self.assertEqual(item.test_results.count(), 3) self.assertEqual(item2.test_results.count(), 3) - StockItemTestResult.objects.create( - stock_item=item2, - test='A new test' - ) + StockItemTestResult.objects.create(stock_item=item2, test='A new test') self.assertEqual(item.test_results.count(), 3) self.assertEqual(item2.test_results.count(), 4) @@ -1154,10 +1098,7 @@ class TestResultTest(StockTestBase): item2.serializeStock(1, [100], self.user) # Add a test result to the parent *after* serialization - StockItemTestResult.objects.create( - stock_item=item2, - test='abcde' - ) + StockItemTestResult.objects.create(stock_item=item2, test='abcde') self.assertEqual(item2.test_results.count(), 5) @@ -1182,10 +1123,7 @@ class TestResultTest(StockTestBase): # Create a stock item which is installed *inside* the master item sub_item = StockItem.objects.create( - part=item.part, - quantity=1, - belongs_to=item, - location=None + part=item.part, quantity=1, belongs_to=item, location=None ) # Now, create some test results against the sub item @@ -1195,7 +1133,7 @@ class TestResultTest(StockTestBase): stock_item=sub_item, test='firmware version', date=datetime.datetime.now().date(), - result=True + result=True, ) # Should return the same number of tests as before diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index 37ea1ce6d9..337bbc4c4b 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -5,26 +5,29 @@ from django.urls import include, path, re_path from stock import views location_urls = [ - - path(r'/', include([ - # Anything else - direct to the location detail view - re_path('^.*$', views.StockLocationDetail.as_view(), name='stock-location-detail'), - ])), - + path( + r'/', + include([ + # Anything else - direct to the location detail view + re_path( + '^.*$', + views.StockLocationDetail.as_view(), + name='stock-location-detail', + ) + ]), + ) ] stock_item_detail_urls = [ # Anything else - direct to the item detail view - re_path('^.*$', views.StockItemDetail.as_view(), name='stock-item-detail'), + re_path('^.*$', views.StockItemDetail.as_view(), name='stock-item-detail') ] stock_urls = [ # Stock location re_path(r'^location/', include(location_urls)), - # Individual stock items re_path(r'^item/(?P\d+)/', include(stock_item_detail_urls)), - # Default to the stock index page re_path(r'^.*$', views.StockIndex.as_view(), name='stock-index'), ] diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 22d3964e30..3fb10d968f 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -34,7 +34,9 @@ class StockIndex(InvenTreeRoleMixin, InvenTreePluginViewMixin, ListView): # No 'ownership' checks are necessary for the top-level StockLocation view context['user_owns_location'] = True context['location_owner'] = None - context['ownership_enabled'] = common.models.InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL') + context['ownership_enabled'] = common.models.InvenTreeSetting.get_setting( + 'STOCK_OWNERSHIP_CONTROL' + ) return context @@ -51,9 +53,13 @@ class StockLocationDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailVi """Extend template context.""" context = super().get_context_data(**kwargs) - context['ownership_enabled'] = common.models.InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL') + context['ownership_enabled'] = common.models.InvenTreeSetting.get_setting( + 'STOCK_OWNERSHIP_CONTROL' + ) context['location_owner'] = context['location'].get_location_owner() - context['user_owns_location'] = context['location'].check_ownership(self.request.user) + context['user_owns_location'] = context['location'].check_ownership( + self.request.user + ) return context @@ -74,14 +80,18 @@ class StockItemDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView): data['previous'] = self.object.get_next_serialized_item(reverse=True) data['next'] = self.object.get_next_serialized_item() - data['ownership_enabled'] = common.models.InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL') + data['ownership_enabled'] = common.models.InvenTreeSetting.get_setting( + 'STOCK_OWNERSHIP_CONTROL' + ) data['item_owner'] = self.object.get_item_owner() data['user_owns_item'] = self.object.check_ownership(self.request.user) # Allocation information data['allocated_to_sales_orders'] = self.object.sales_order_allocation_count() data['allocated_to_build_orders'] = self.object.build_allocation_count() - data['allocated_to_orders'] = data['allocated_to_sales_orders'] + data['allocated_to_build_orders'] + data['allocated_to_orders'] = ( + data['allocated_to_sales_orders'] + data['allocated_to_build_orders'] + ) data['available'] = max(0, self.object.quantity - data['allocated_to_orders']) return data diff --git a/InvenTree/users/admin.py b/InvenTree/users/admin.py index 2b77421ce8..67629b381e 100644 --- a/InvenTree/users/admin.py +++ b/InvenTree/users/admin.py @@ -18,17 +18,34 @@ class ApiTokenAdmin(admin.ModelAdmin): list_display = ('token', 'user', 'name', 'expiry', 'active') list_filter = ('user', 'revoked') - fields = ('token', 'user', 'name', 'created', 'last_seen', 'revoked', 'expiry', 'metadata') + fields = ( + 'token', + 'user', + 'name', + 'created', + 'last_seen', + 'revoked', + 'expiry', + 'metadata', + ) def get_fields(self, request, obj=None): """Return list of fields to display.""" if obj: - fields = ['token',] + fields = ['token'] else: - fields = ['key',] + fields = ['key'] - fields += ['user', 'name', 'created', 'last_seen', 'revoked', 'expiry', 'metadata'] + fields += [ + 'user', + 'name', + 'created', + 'last_seen', + 'revoked', + 'expiry', + 'metadata', + ] return fields @@ -67,12 +84,10 @@ class InvenTreeGroupAdminForm(forms.ModelForm): class Meta: """Metaclass defines extra fields""" + model = Group exclude = [] - fields = [ - 'name', - 'users', - ] + fields = ['name', 'users'] def __init__(self, *args, **kwargs): # pragma: no cover """Populate the 'users' field with the users in the current group""" @@ -88,7 +103,7 @@ class InvenTreeGroupAdminForm(forms.ModelForm): required=False, widget=FilteredSelectMultiple('users', False), label=_('Users'), - help_text=_('Select which users are assigned to this group') + help_text=_('Select which users are assigned to this group'), ) def save_m2m(self): # pragma: no cover @@ -109,12 +124,21 @@ class RoleGroupAdmin(admin.ModelAdmin): # pragma: no cover form = InvenTreeGroupAdminForm - inlines = [ - RuleSetInline, - ] + inlines = [RuleSetInline] - list_display = ('name', 'admin', 'part_category', 'part', 'stocktake', 'stock_location', - 'stock_item', 'build', 'purchase_order', 'sales_order', 'return_order') + list_display = ( + 'name', + 'admin', + 'part_category', + 'part', + 'stocktake', + 'stock_location', + 'stock_item', + 'build', + 'purchase_order', + 'sales_order', + 'return_order', + ) def get_rule_set(self, obj, rule_set_type): """Return list of permissions for the given ruleset.""" @@ -222,15 +246,14 @@ class RoleGroupAdmin(admin.ModelAdmin): # pragma: no cover # If any, display warning message when group is saved if len(multiple_group_users) > 0: - - msg = _("The following users are members of multiple groups") + ": " + ", ".join(multiple_group_users) - - messages.add_message( - request, - messages.WARNING, - msg + msg = ( + _("The following users are members of multiple groups") + + ": " + + ", ".join(multiple_group_users) ) + messages.add_message(request, messages.WARNING, msg) + def save_formset(self, request, form, formset, change): """Save the inline formset""" # Save inline Rulesets @@ -247,19 +270,29 @@ class InvenTreeUserAdmin(UserAdmin): (And it's confusing!) """ - list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff', 'last_login') # display last connection for each user in user admin panel. + + list_display = ( + 'username', + 'email', + 'first_name', + 'last_name', + 'is_staff', + 'last_login', + ) # display last connection for each user in user admin panel. fieldsets = ( (None, {'fields': ('username', 'password')}), (_('Personal info'), {'fields': ('first_name', 'last_name', 'email')}), - (_('Permissions'), { - 'fields': ('is_active', 'is_staff', 'is_superuser', 'groups'), - }), + ( + _('Permissions'), + {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups')}, + ), (_('Important dates'), {'fields': ('last_login', 'date_joined')}), ) class OwnerAdmin(admin.ModelAdmin): """Custom admin interface for the Owner model.""" + pass diff --git a/InvenTree/users/api.py b/InvenTree/users/api.py index 3486bf6ebb..1c4f6611d5 100644 --- a/InvenTree/users/api.py +++ b/InvenTree/users/api.py @@ -12,8 +12,13 @@ from rest_framework.views import APIView import InvenTree.helpers from InvenTree.filters import SEARCH_ORDER_FILTER -from InvenTree.mixins import (ListAPI, ListCreateAPI, RetrieveAPI, - RetrieveUpdateAPI, RetrieveUpdateDestroyAPI) +from InvenTree.mixins import ( + ListAPI, + ListCreateAPI, + RetrieveAPI, + RetrieveUpdateAPI, + RetrieveUpdateDestroyAPI, +) from InvenTree.serializers import ExendedUserSerializer, UserCreateSerializer from users.models import ApiToken, Owner, RuleSet, check_user_role from users.serializers import GroupSerializer, OwnerSerializer @@ -52,10 +57,11 @@ class OwnerList(ListAPI): # Get a list of all matching users, depending on the *is_active* flag if is_active is not None: is_active = InvenTree.helpers.str2bool(is_active) - matching_user_ids = User.objects.filter(is_active=is_active).values_list('pk', flat=True) + matching_user_ids = User.objects.filter(is_active=is_active).values_list( + 'pk', flat=True + ) for result in queryset.all(): - name = str(result.name()).lower().strip() search_match = True @@ -71,7 +77,10 @@ class OwnerList(ListAPI): if is_active is not None: # Skip any users which do not match the required *is_active* value - if result.owner_type.name == 'user' and result.owner_id not in matching_user_ids: + if ( + result.owner_type.name == 'user' + and result.owner_id not in matching_user_ids + ): continue # If we get here, there is no reason *not* to include this result @@ -96,9 +105,7 @@ class RoleDetails(APIView): (Requires authentication) """ - permission_classes = [ - permissions.IsAuthenticated - ] + permission_classes = [permissions.IsAuthenticated] def get(self, request, *args, **kwargs): """Return the list of roles / permissions available to the current user""" @@ -107,14 +114,12 @@ class RoleDetails(APIView): roles = {} for ruleset in RuleSet.RULESET_CHOICES: - role, _text = ruleset permissions = [] for permission in RuleSet.RULESET_PERMISSIONS: if check_user_role(user, role, permission): - permissions.append(permission) if len(permissions) > 0: @@ -138,9 +143,7 @@ class UserDetail(RetrieveUpdateDestroyAPI): queryset = User.objects.all() serializer_class = ExendedUserSerializer - permission_classes = [ - permissions.IsAuthenticated - ] + permission_classes = [permissions.IsAuthenticated] class MeUserDetail(RetrieveUpdateAPI, UserDetail): @@ -156,16 +159,10 @@ class UserList(ListCreateAPI): queryset = User.objects.all() serializer_class = UserCreateSerializer - permission_classes = [ - permissions.IsAuthenticated, - ] + permission_classes = [permissions.IsAuthenticated] filter_backends = SEARCH_ORDER_FILTER - search_fields = [ - 'first_name', - 'last_name', - 'username', - ] + search_fields = ['first_name', 'last_name', 'username'] ordering_fields = [ 'email', @@ -177,11 +174,7 @@ class UserList(ListCreateAPI): 'is_active', ] - filterset_fields = [ - 'is_staff', - 'is_active', - 'is_superuser', - ] + filterset_fields = ['is_staff', 'is_active', 'is_superuser'] class GroupDetail(RetrieveUpdateDestroyAPI): @@ -189,9 +182,7 @@ class GroupDetail(RetrieveUpdateDestroyAPI): queryset = Group.objects.all() serializer_class = GroupSerializer - permission_classes = [ - permissions.IsAuthenticated, - ] + permission_classes = [permissions.IsAuthenticated] class GroupList(ListCreateAPI): @@ -199,27 +190,19 @@ class GroupList(ListCreateAPI): queryset = Group.objects.all() serializer_class = GroupSerializer - permission_classes = [ - permissions.IsAuthenticated, - ] + permission_classes = [permissions.IsAuthenticated] filter_backends = SEARCH_ORDER_FILTER - search_fields = [ - 'name', - ] + search_fields = ['name'] - ordering_fields = [ - 'name', - ] + ordering_fields = ['name'] class GetAuthToken(APIView): """Return authentication token for an authenticated user.""" - permission_classes = [ - permissions.IsAuthenticated, - ] + permission_classes = [permissions.IsAuthenticated] def get(self, request, *args, **kwargs): """Return an API token if the user is authenticated @@ -230,7 +213,6 @@ class GetAuthToken(APIView): """ if request.user.is_authenticated: - user = request.user name = request.query_params.get('name', '') @@ -239,7 +221,9 @@ class GetAuthToken(APIView): today = datetime.date.today() # Find existing token, which has not expired - token = ApiToken.objects.filter(user=user, name=name, revoked=False, expiry__gte=today).first() + token = ApiToken.objects.filter( + user=user, name=name, revoked=False, expiry__gte=today + ).first() if not token: # User is authenticated, and requesting a token against the provided name. @@ -253,13 +237,11 @@ class GetAuthToken(APIView): token.set_metadata('server_name', request.META.get('SERVER_NAME', '')) token.set_metadata('server_port', request.META.get('SERVER_PORT', '')) - data = { - 'token': token.key, - 'name': token.name, - 'expiry': token.expiry, - } + data = {'token': token.key, 'name': token.name, 'expiry': token.expiry} - logger.info("Created new API token for user '%s' (name='%s')", user.username, name) + logger.info( + "Created new API token for user '%s' (name='%s')", user.username, name + ) return Response(data) @@ -268,22 +250,25 @@ class GetAuthToken(APIView): user_urls = [ - re_path(r'roles/?$', RoleDetails.as_view(), name='api-user-roles'), re_path(r'token/?$', GetAuthToken.as_view(), name='api-token'), re_path(r'^me/', MeUserDetail.as_view(), name='api-user-me'), - - re_path(r'^owner/', include([ - path('/', OwnerDetail.as_view(), name='api-owner-detail'), - re_path(r'^.*$', OwnerList.as_view(), name='api-owner-list'), - ])), - - re_path(r'^group/', include([ - re_path(r'^(?P[0-9]+)/?$', GroupDetail.as_view(), name='api-group-detail'), - re_path(r'^.*$', GroupList.as_view(), name='api-group-list'), - ])), - + re_path( + r'^owner/', + include([ + path('/', OwnerDetail.as_view(), name='api-owner-detail'), + re_path(r'^.*$', OwnerList.as_view(), name='api-owner-list'), + ]), + ), + re_path( + r'^group/', + include([ + re_path( + r'^(?P[0-9]+)/?$', GroupDetail.as_view(), name='api-group-detail' + ), + re_path(r'^.*$', GroupList.as_view(), name='api-group-list'), + ]), + ), re_path(r'^(?P[0-9]+)/?$', UserDetail.as_view(), name='api-user-detail'), - path('', UserList.as_view(), name='api-user-list'), ] diff --git a/InvenTree/users/apps.py b/InvenTree/users/apps.py index bab4c03865..0719369218 100644 --- a/InvenTree/users/apps.py +++ b/InvenTree/users/apps.py @@ -5,8 +5,7 @@ import logging from django.apps import AppConfig from django.db.utils import OperationalError, ProgrammingError -from InvenTree.ready import (canAppAccessDatabase, isInMainThread, - isPluginRegistryLoaded) +from InvenTree.ready import canAppAccessDatabase, isInMainThread, isPluginRegistryLoaded logger = logging.getLogger('inventree') @@ -23,7 +22,6 @@ class UsersConfig(AppConfig): return if canAppAccessDatabase(allow_test=True): - try: self.assign_permissions() except (OperationalError, ProgrammingError): @@ -42,13 +40,14 @@ class UsersConfig(AppConfig): # First, delete any rule_set objects which have become outdated! for rule in RuleSet.objects.all(): - if rule.name not in RuleSet.RULESET_NAMES: # pragma: no cover # can not change ORM without the app being loaded + if ( + rule.name not in RuleSet.RULESET_NAMES + ): # pragma: no cover # can not change ORM without the app being loaded logger.info("Deleting outdated ruleset: %s", rule.name) rule.delete() # Update group permission assignments for all groups for group in Group.objects.all(): - update_group_roles(group) def update_owners(self): diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index 4cd01439ed..1d8753ef4d 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -67,6 +67,7 @@ class ApiToken(AuthToken, InvenTree.models.MetadataMixin): class Meta: """Metaclass defines model properties""" + verbose_name = _('API Token') verbose_name_plural = _('API Tokens') abstract = False @@ -88,11 +89,10 @@ class ApiToken(AuthToken, InvenTree.models.MetadataMixin): key = models.CharField( default=default_token, verbose_name=_('Key'), - db_index=True, unique=True, + db_index=True, + unique=True, max_length=100, - validators=[ - MinLengthValidator(50), - ] + validators=[MinLengthValidator(50)], ) # Override the 'user' field, to allow multiple tokens per user @@ -114,19 +114,19 @@ class ApiToken(AuthToken, InvenTree.models.MetadataMixin): default=default_token_expiry, verbose_name=_('Expiry Date'), help_text=_('Token expiry date'), - auto_now=False, auto_now_add=False, + auto_now=False, + auto_now_add=False, ) last_seen = models.DateField( - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Last Seen'), help_text=_('Last time the token was used'), ) revoked = models.BooleanField( - default=False, - verbose_name=_('Revoked'), - help_text=_('Token has been revoked'), + default=False, verbose_name=_('Revoked'), help_text=_('Token has been revoked') ) @staticmethod @@ -136,7 +136,9 @@ class ApiToken(AuthToken, InvenTree.models.MetadataMixin): name = str(name).strip() # Remove any non-printable chars - name = InvenTree.helpers.remove_non_printable_characters(name, remove_newline=True) + name = InvenTree.helpers.remove_non_printable_characters( + name, remove_newline=True + ) name = InvenTree.helpers.strip_html_tags(name) name = name.replace(' ', '-') @@ -199,13 +201,9 @@ class RuleSet(models.Model): ('return_order', _('Return Orders')), ] - RULESET_NAMES = [ - choice[0] for choice in RULESET_CHOICES - ] + RULESET_NAMES = [choice[0] for choice in RULESET_CHOICES] - RULESET_PERMISSIONS = [ - 'view', 'add', 'change', 'delete', - ] + RULESET_PERMISSIONS = ['view', 'add', 'change', 'delete'] RULESET_MODELS = { 'admin': [ @@ -261,15 +259,12 @@ class RuleSet(models.Model): 'company_manufacturerpartattachment', 'label_partlabel', ], - 'stocktake': [ - 'part_partstocktake', - 'part_partstocktakereport', - ], + 'stocktake': ['part_partstocktake', 'part_partstocktakereport'], 'stock_location': [ 'stock_stocklocation', 'stock_stocklocationtype', 'label_stocklocationlabel', - 'report_stocklocationreport' + 'report_stocklocationreport', ], 'stock': [ 'stock_stockitem', @@ -331,7 +326,7 @@ class RuleSet(models.Model): 'order_returnorderextraline', 'order_returnorderattachment', 'report_returnorderreport', - ] + ], } # Database models we ignore permission sets for @@ -339,7 +334,6 @@ class RuleSet(models.Model): # Core django models (not user configurable) 'admin_logentry', 'contenttypes_contenttype', - # Models which currently do not require permissions 'common_colortheme', 'common_customunit', @@ -353,13 +347,11 @@ class RuleSet(models.Model): 'common_webhookmessage', 'label_labeloutput', 'users_owner', - # Third-party tables 'error_report_error', 'exchange_rate', 'exchange_exchangebackend', 'user_sessions_session', - # Django-q 'django_q_ormq', 'django_q_failure', @@ -368,46 +360,50 @@ class RuleSet(models.Model): 'django_q_success', ] - RULESET_CHANGE_INHERIT = [ - ('part', 'partparameter'), - ('part', 'bomitem'), - ] + RULESET_CHANGE_INHERIT = [('part', 'partparameter'), ('part', 'bomitem')] - RULE_OPTIONS = [ - 'can_view', - 'can_add', - 'can_change', - 'can_delete', - ] + RULE_OPTIONS = ['can_view', 'can_add', 'can_change', 'can_delete'] class Meta: """Metaclass defines additional model properties""" - unique_together = ( - ('name', 'group'), - ) + + unique_together = (('name', 'group'),) name = models.CharField( max_length=50, choices=RULESET_CHOICES, blank=False, - help_text=_('Permission set') + help_text=_('Permission set'), ) group = models.ForeignKey( Group, related_name='rule_sets', - blank=False, null=False, + blank=False, + null=False, on_delete=models.CASCADE, help_text=_('Group'), ) - can_view = models.BooleanField(verbose_name=_('View'), default=True, help_text=_('Permission to view items')) + can_view = models.BooleanField( + verbose_name=_('View'), default=True, help_text=_('Permission to view items') + ) - can_add = models.BooleanField(verbose_name=_('Add'), default=False, help_text=_('Permission to add items')) + can_add = models.BooleanField( + verbose_name=_('Add'), default=False, help_text=_('Permission to add items') + ) - can_change = models.BooleanField(verbose_name=_('Change'), default=False, help_text=_('Permissions to edit items')) + can_change = models.BooleanField( + verbose_name=_('Change'), + default=False, + help_text=_('Permissions to edit items'), + ) - can_delete = models.BooleanField(verbose_name=_('Delete'), default=False, help_text=_('Permission to delete items')) + can_delete = models.BooleanField( + verbose_name=_('Delete'), + default=False, + help_text=_('Permission to delete items'), + ) @classmethod def check_table_permission(cls, user, table, permission): @@ -423,12 +419,11 @@ class RuleSet(models.Model): # Work out which roles touch the given table for role in cls.RULESET_NAMES: if table in cls.RULESET_MODELS[role]: - if check_user_role(user, role, permission): return True # Check for children models which inherits from parent role - for (parent, child) in cls.RULESET_CHANGE_INHERIT: + for parent, child in cls.RULESET_CHANGE_INHERIT: # Get child model name parent_child_string = f'{parent}_{child}' @@ -439,7 +434,9 @@ class RuleSet(models.Model): # Print message instead of throwing an error name = getattr(user, 'name', user.pk) - logger.debug("User '%s' failed permission check for %s.%s", name, table, permission) + logger.debug( + "User '%s' failed permission check for %s.%s", name, table, permission + ) return False @@ -454,9 +451,11 @@ class RuleSet(models.Model): """Ruleset string representation.""" if debug: # Makes debugging easier - return f'{str(self.group).ljust(15)}: {self.name.title().ljust(15)} | ' \ - f'v: {str(self.can_view).ljust(5)} | a: {str(self.can_add).ljust(5)} | ' \ - f'c: {str(self.can_change).ljust(5)} | d: {str(self.can_delete).ljust(5)}' + return ( + f'{str(self.group).ljust(15)}: {self.name.title().ljust(15)} | ' + f'v: {str(self.can_view).ljust(5)} | a: {str(self.can_add).ljust(5)} | ' + f'c: {str(self.can_change).ljust(5)} | d: {str(self.can_delete).ljust(5)}' + ) return self.name def save(self, *args, **kwargs): @@ -549,7 +548,6 @@ def update_group_roles(group, debug=False): permission_string = RuleSet.get_model_permission_string(model, action) if allowed: - # An 'allowed' action is always preferenced over a 'forbidden' action if permission_string in permissions_to_delete: permissions_to_delete.remove(permission_string) @@ -557,7 +555,6 @@ def update_group_roles(group, debug=False): permissions_to_add.add(permission_string) else: - # A forbidden action will be ignored if we have already allowed it if permission_string not in permissions_to_add: permissions_to_delete.add(permission_string) @@ -569,7 +566,6 @@ def update_group_roles(group, debug=False): # Get all the rulesets associated with this group for r in RuleSet.RULESET_CHOICES: - rulename = r[0] if rulename in rulesets: @@ -605,16 +601,19 @@ def update_group_roles(group, debug=False): try: content_type = ContentType.objects.get(app_label=app, model=model) - permission = Permission.objects.get(content_type=content_type, codename=perm) + permission = Permission.objects.get( + content_type=content_type, codename=perm + ) except ContentType.DoesNotExist: # pragma: no cover - logger.warning("Error: Could not find permission matching '%s'", permission_string) + logger.warning( + "Error: Could not find permission matching '%s'", permission_string + ) permission = None return permission # Add any required permissions to the group for perm in permissions_to_add: - # Ignore if permission is already in the group if perm in group_permissions: continue @@ -629,7 +628,6 @@ def update_group_roles(group, debug=False): # Remove any extra permissions from the group for perm in permissions_to_delete: - # Ignore if the permission is not already assigned if perm not in group_permissions: continue @@ -644,7 +642,7 @@ def update_group_roles(group, debug=False): # Enable all action permissions for certain children models # if parent model has 'change' permission - for (parent, child) in RuleSet.RULESET_CHANGE_INHERIT: + for parent, child in RuleSet.RULESET_CHANGE_INHERIT: parent_child_string = f'{parent}_{child}' # Check each type of permission @@ -662,7 +660,9 @@ def update_group_roles(group, debug=False): permission = get_permission_object(child_perm) if permission: group.permissions.add(permission) - logger.debug("Adding permission %s to group %s", child_perm, group.name) + logger.debug( + "Adding permission %s to group %s", child_perm, group.name + ) def clear_user_role_cache(user): @@ -718,11 +718,8 @@ def check_user_role(user, role, permission): result = False for group in user.groups.all(): - for rule in group.rule_sets.all(): - if rule.name == role: - if permission == 'add' and rule.can_add: result = True break @@ -756,10 +753,10 @@ class Owner(models.Model): class Meta: """Metaclass defines extra model properties""" + # Ensure all owners are unique constraints = [ - UniqueConstraint(fields=['owner_type', 'owner_id'], - name='unique_owner') + UniqueConstraint(fields=['owner_type', 'owner_id'], name='unique_owner') ] @classmethod @@ -794,7 +791,9 @@ class Owner(models.Model): """Returns the API endpoint URL associated with the Owner model""" return reverse('api-owner-list') - owner_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, null=True, blank=True) + owner_type = models.ForeignKey( + ContentType, on_delete=models.CASCADE, null=True, blank=True + ) owner_id = models.PositiveIntegerField(null=True, blank=True) @@ -802,7 +801,10 @@ class Owner(models.Model): def __str__(self): """Defines the owner string representation.""" - if self.owner_type.name == 'user' and common_models.InvenTreeSetting.get_setting('DISPLAY_FULL_NAMES'): + if ( + self.owner_type.name == 'user' + and common_models.InvenTreeSetting.get_setting('DISPLAY_FULL_NAMES') + ): display_name = self.owner.get_full_name() else: display_name = str(self.owner) @@ -810,7 +812,10 @@ class Owner(models.Model): def name(self): """Return the 'name' of this owner.""" - if self.owner_type.name == 'user' and common_models.InvenTreeSetting.get_setting('DISPLAY_FULL_NAMES'): + if ( + self.owner_type.name == 'user' + and common_models.InvenTreeSetting.get_setting('DISPLAY_FULL_NAMES') + ): return self.owner.get_full_name() return str(self.owner) @@ -839,8 +844,10 @@ class Owner(models.Model): user_model = get_user_model() owner = None content_type_id = 0 - content_type_id_list = [ContentType.objects.get_for_model(Group).id, - ContentType.objects.get_for_model(user_model).id] + content_type_id_list = [ + ContentType.objects.get_for_model(Group).id, + ContentType.objects.get_for_model(user_model).id, + ] # If instance type is obvious: set content type if isinstance(user_or_group, Group): @@ -850,8 +857,9 @@ class Owner(models.Model): if content_type_id: try: - owner = Owner.objects.get(owner_id=user_or_group.id, - owner_type=content_type_id) + owner = Owner.objects.get( + owner_id=user_or_group.id, owner_type=content_type_id + ) except Owner.DoesNotExist: pass @@ -870,10 +878,18 @@ class Owner(models.Model): if include_group: # Include "group-type" owner in the query - query = Q(owner_id__in=users, owner_type=ContentType.objects.get_for_model(user_model).id) | \ - Q(owner_id=self.owner.id, owner_type=ContentType.objects.get_for_model(Group).id) + query = Q( + owner_id__in=users, + owner_type=ContentType.objects.get_for_model(user_model).id, + ) | Q( + owner_id=self.owner.id, + owner_type=ContentType.objects.get_for_model(Group).id, + ) else: - query = Q(owner_id__in=users, owner_type=ContentType.objects.get_for_model(user_model).id) + query = Q( + owner_id__in=users, + owner_type=ContentType.objects.get_for_model(user_model).id, + ) related_owners = Owner.objects.filter(query) diff --git a/InvenTree/users/serializers.py b/InvenTree/users/serializers.py index 9b4fd48353..0aa5502e14 100644 --- a/InvenTree/users/serializers.py +++ b/InvenTree/users/serializers.py @@ -14,13 +14,9 @@ class OwnerSerializer(InvenTreeModelSerializer): class Meta: """Metaclass defines serializer fields.""" + model = Owner - fields = [ - 'pk', - 'owner_id', - 'name', - 'label', - ] + fields = ['pk', 'owner_id', 'name', 'label'] name = serializers.CharField(read_only=True) @@ -34,7 +30,4 @@ class GroupSerializer(InvenTreeModelSerializer): """Metaclass defines serializer fields""" model = Group - fields = [ - 'pk', - 'name', - ] + fields = ['pk', 'name'] diff --git a/InvenTree/users/test_api.py b/InvenTree/users/test_api.py index 96039153db..5417a3920a 100644 --- a/InvenTree/users/test_api.py +++ b/InvenTree/users/test_api.py @@ -14,10 +14,7 @@ class UserAPITests(InvenTreeAPITestCase): def test_user_api(self): """Tests for User API endpoints""" - response = self.get( - reverse('api-user-list'), - expected_code=200 - ) + response = self.get(reverse('api-user-list'), expected_code=200) # Check the correct number of results was returned self.assertEqual(len(response.data), User.objects.count()) @@ -29,8 +26,7 @@ class UserAPITests(InvenTreeAPITestCase): pk = response.data[0]['pk'] response = self.get( - reverse('api-user-detail', kwargs={'pk': pk}), - expected_code=200 + reverse('api-user-detail', kwargs={'pk': pk}), expected_code=200 ) self.assertIn('pk', response.data) @@ -38,10 +34,7 @@ class UserAPITests(InvenTreeAPITestCase): def test_group_api(self): """Tests for the Group API endpoints""" - response = self.get( - reverse('api-group-list'), - expected_code=200, - ) + response = self.get(reverse('api-group-list'), expected_code=200) self.assertIn('name', response.data[0]) @@ -106,7 +99,14 @@ class UserTokenTests(InvenTreeAPITestCase): token.refresh_from_db() # Check that the metadata has been updated - keys = ['user_agent', 'remote_addr', 'remote_host', 'remote_user', 'server_name', 'server_port'] + keys = [ + 'user_agent', + 'remote_addr', + 'remote_host', + 'remote_user', + 'server_name', + 'server_port', + ] for k in keys: self.assertIn(k, token.metadata) @@ -115,7 +115,9 @@ class UserTokenTests(InvenTreeAPITestCase): """Test user token authentication""" # Create a new token - token_key = self.get(url=reverse('api-token'), data={'name': 'test'}, expected_code=200).data['token'] + token_key = self.get( + url=reverse('api-token'), data={'name': 'test'}, expected_code=200 + ).data['token'] # Check that we can use the token to authenticate self.client.logout() diff --git a/InvenTree/users/test_migrations.py b/InvenTree/users/test_migrations.py index 9cc3b2af24..707546ffb5 100644 --- a/InvenTree/users/test_migrations.py +++ b/InvenTree/users/test_migrations.py @@ -15,17 +15,9 @@ class TestForwardMigrations(MigratorTestCase): """Setup the initial state of the database before migrations""" User = self.old_state.apps.get_model('auth', 'user') - User.objects.create( - username='fred', - email='fred@fred.com', - password='password' - ) + User.objects.create(username='fred', email='fred@fred.com', password='password') - User.objects.create( - username='brad', - email='brad@fred.com', - password='password' - ) + User.objects.create(username='brad', email='brad@fred.com', password='password') def test_users_exist(self): """Test that users exist in the database""" diff --git a/InvenTree/users/tests.py b/InvenTree/users/tests.py index de1c443ef5..21ff21805b 100644 --- a/InvenTree/users/tests.py +++ b/InvenTree/users/tests.py @@ -29,7 +29,9 @@ class RuleSetModelTest(TestCase): extra = [name for name in keys if name not in RuleSet.RULESET_NAMES] if len(extra) > 0: # pragma: no cover - print("The following rulesets have been improperly added to RULESET_MODELS:") + print( + "The following rulesets have been improperly added to RULESET_MODELS:" + ) for e in extra: print("-", e) @@ -61,21 +63,23 @@ class RuleSetModelTest(TestCase): # Now check that each defined model is a valid table name for key in RuleSet.RULESET_MODELS.keys(): - models = RuleSet.RULESET_MODELS[key] for m in models: - assigned_models.add(m) missing_models = set() for model in available_tables: - if model not in assigned_models and model not in RuleSet.RULESET_IGNORE: # pragma: no cover + if ( + model not in assigned_models and model not in RuleSet.RULESET_IGNORE + ): # pragma: no cover missing_models.add(model) if len(missing_models) > 0: # pragma: no cover - print("The following database models are not covered by the defined RuleSet permissions:") + print( + "The following database models are not covered by the defined RuleSet permissions:" + ) for m in missing_models: print("-", m) @@ -115,7 +119,6 @@ class RuleSetModelTest(TestCase): permission_set = set() for models in RuleSet.RULESET_MODELS.values(): - for model in models: permission_set.add(model) @@ -208,7 +211,9 @@ class OwnerModelTest(InvenTreeTestCase): # not authed self.do_request(reverse('api-owner-list'), {}, 401) - self.do_request(reverse('api-owner-detail', kwargs={'pk': self.user.id}), {}, 401) + self.do_request( + reverse('api-owner-detail', kwargs={'pk': self.user.id}), {}, 401 + ) self.client.login(username=self.username, password=self.password) # user list @@ -230,7 +235,9 @@ class OwnerModelTest(InvenTreeTestCase): # self.assertEqual(response['owner_id'], group.pk) # own user detail - response_detail = self.do_request(reverse('api-user-detail', kwargs={'pk': self.user.id}), {}, 200) + response_detail = self.do_request( + reverse('api-user-detail', kwargs={'pk': self.user.id}), {}, 200 + ) self.assertEqual(response_detail['username'], self.username) response_me = self.do_request(reverse('api-user-me'), {}, 200) @@ -251,5 +258,7 @@ class OwnerModelTest(InvenTreeTestCase): self.assertEqual(response['token'], token.first().key) # test user is associated with token - response = self.do_request(reverse('api-user-me'), {'name': 'another-token'}, 200) + response = self.do_request( + reverse('api-user-me'), {'name': 'another-token'}, 200 + ) self.assertEqual(response['username'], self.username) diff --git a/InvenTree/web/templatetags/spa_helper.py b/InvenTree/web/templatetags/spa_helper.py index 5da2a0add9..9b2e09be45 100644 --- a/InvenTree/web/templatetags/spa_helper.py +++ b/InvenTree/web/templatetags/spa_helper.py @@ -18,12 +18,15 @@ FRONTEND_SETTINGS = json.dumps(settings.FRONTEND_SETTINGS) @register.simple_tag def spa_bundle(manifest_path: Union[str, Path] = '', app: str = 'web'): """Render SPA bundle.""" + def get_url(file: str) -> str: """Get static url for file.""" return f"{settings.STATIC_URL}{app}/{file}" if manifest_path == '': - manifest_path = Path(__file__).parent.parent.joinpath("static/web/manifest.json") + manifest_path = Path(__file__).parent.parent.joinpath( + "static/web/manifest.json" + ) manifest = Path(manifest_path) if not manifest.exists(): @@ -40,18 +43,20 @@ def spa_bundle(manifest_path: Union[str, Path] = '', app: str = 'web'): # CSS (based on index.css file as entrypoint) css_index = manifest_data.get("index.css") if css_index: - return_string += f'' + return_string += ( + f'' + ) # JS (based on index.html file as entrypoint) index = manifest_data.get("index.html") dynamic_files = index.get("dynamicImports", []) - imports_files = "".join( - [ - f'' - for file in dynamic_files - ] + imports_files = "".join([ + f'' + for file in dynamic_files + ]) + return_string += ( + f'{imports_files}' ) - return_string += f'{imports_files}' return mark_safe(return_string) @@ -59,4 +64,6 @@ def spa_bundle(manifest_path: Union[str, Path] = '', app: str = 'web'): @register.simple_tag def spa_settings(): """Render settings for spa.""" - return mark_safe(f"""""") + return mark_safe( + f"""""" + ) diff --git a/InvenTree/web/tests.py b/InvenTree/web/tests.py index 7822781f80..0419741564 100644 --- a/InvenTree/web/tests.py +++ b/InvenTree/web/tests.py @@ -1,4 +1,5 @@ """Tests for PUI backend stuff.""" + import json import os from pathlib import Path @@ -23,7 +24,9 @@ class TemplateTagTest(InvenTreeTestCase): def test_spa_bundle(self): """Test the 'spa_bundle' template tag""" resp = spa_helper.spa_bundle() - self.assertTrue(resp.startswith('window.INVENTREE_SETTINGS=')) - settings_data_string = resp.replace('', '') + settings_data_string = resp.replace( + '', '') settings_data = json.loads(settings_data_string) self.assertSettings(settings_data) @@ -61,8 +68,8 @@ class TemplateTagTest(InvenTreeTestCase): self.assertTrue(rsp['show_server_selector']) # No debug, serverlist -> no selector - envs = {'INVENTREE_PUI_SETTINGS': json.dumps({'server_list': ['aa', 'bb',]})} + envs = {'INVENTREE_PUI_SETTINGS': json.dumps({'server_list': ['aa', 'bb']})} with mock.patch.dict(os.environ, envs): rsp = get_frontend_settings(False) self.assertFalse('show_server_selector' in rsp) - self.assertEqual(rsp['server_list'], ['aa', 'bb',]) + self.assertEqual(rsp['server_list'], ['aa', 'bb']) diff --git a/InvenTree/web/urls.py b/InvenTree/web/urls.py index ea3f893729..02d24a8033 100644 --- a/InvenTree/web/urls.py +++ b/InvenTree/web/urls.py @@ -1,4 +1,5 @@ """URLs for web app.""" + from django.conf import settings from django.shortcuts import redirect from django.urls import include, path, re_path @@ -21,11 +22,18 @@ assets_path = path("assets/", RedirectAssetView.as_view()) urlpatterns = [ - path(f"{settings.FRONTEND_URL_BASE}/", include([ - assets_path, - path("set-password?uid=&token=", spa_view, name="password_reset_confirm",), - re_path(".*", spa_view), - ])), + path( + f"{settings.FRONTEND_URL_BASE}/", + include([ + assets_path, + path( + "set-password?uid=&token=", + spa_view, + name="password_reset_confirm", + ), + re_path(".*", spa_view), + ]), + ), assets_path, path(settings.FRONTEND_URL_BASE, spa_view, name="platform"), ] diff --git a/ci/check_api_endpoint.py b/ci/check_api_endpoint.py index ad57ec84fb..ef263ee9f1 100644 --- a/ci/check_api_endpoint.py +++ b/ci/check_api_endpoint.py @@ -17,12 +17,7 @@ print("- Response 200 OK") data = json.loads(response.text) -required_keys = [ - 'server', - 'version', - 'apiVersion', - 'worker_running', -] +required_keys = ['server', 'version', 'apiVersion', 'worker_running'] for key in required_keys: assert key in data diff --git a/ci/check_js_templates.py b/ci/check_js_templates.py index 0b80901ffa..f51a89a243 100644 --- a/ci/check_js_templates.py +++ b/ci/check_js_templates.py @@ -30,7 +30,6 @@ def check_invalid_tag(data): err_count = 0 for idx, line in enumerate(data): - results = re.findall(pattern, line) for result in results: @@ -61,9 +60,7 @@ def check_prohibited_tags(data): err_count = 0 for idx, line in enumerate(data): - for tag in re.findall(pattern, line): - if tag not in allowed_tags: print(f" > Line {idx+1} contains prohibited template tag '{tag}'") err_count += 1 @@ -72,7 +69,6 @@ def check_prohibited_tags(data): for filename in pathlib.Path(js_i18n_dir).rglob('*.js'): - print(f"Checking file 'translated/{os.path.basename(filename)}':") with open(filename, 'r') as js_file: @@ -82,7 +78,6 @@ for filename in pathlib.Path(js_i18n_dir).rglob('*.js'): errors += check_prohibited_tags(data) for filename in pathlib.Path(js_dynamic_dir).rglob('*.js'): - print(f"Checking file 'dynamic/{os.path.basename(filename)}':") # Check that the 'dynamic' files do not contains any translated strings @@ -94,7 +89,6 @@ for filename in pathlib.Path(js_dynamic_dir).rglob('*.js'): err_count = 0 for idx, line in enumerate(data): - results = re.findall(pattern, line) if len(results) > 0: diff --git a/ci/version_check.py b/ci/version_check.py index 0ea59b73f4..39aa52bbe0 100644 --- a/ci/version_check.py +++ b/ci/version_check.py @@ -26,14 +26,16 @@ def get_existing_release_tags(): headers = None if token: - headers = { - "Authorization": f"Bearer {token}" - } + headers = {"Authorization": f"Bearer {token}"} - response = requests.get('https://api.github.com/repos/inventree/inventree/releases', headers=headers) + response = requests.get( + 'https://api.github.com/repos/inventree/inventree/releases', headers=headers + ) if response.status_code != 200: - raise ValueError(f'Unexpected status code from GitHub API: {response.status_code}') + raise ValueError( + f'Unexpected status code from GitHub API: {response.status_code}' + ) data = json.loads(response.text) @@ -65,7 +67,9 @@ def check_version_number(version_string, allow_duplicate=False): match = re.match(r"^(\d+)\.(\d+)\.(\d+)(?: dev)?$", version_string) if not match or len(match.groups()) != 3: - raise ValueError(f"Version string '{version_string}' did not match required pattern") + raise ValueError( + f"Version string '{version_string}' did not match required pattern" + ) version_tuple = [int(x) for x in match.groups()] @@ -87,7 +91,6 @@ def check_version_number(version_string, allow_duplicate=False): if __name__ == '__main__': - here = os.path.abspath(os.path.dirname(__file__)) # GITHUB_REF_TYPE may be either 'branch' or 'tag' @@ -111,7 +114,6 @@ if __name__ == '__main__': version = None with open(version_file, 'r') as f: - text = f.read() # Extract the InvenTree software version @@ -178,7 +180,6 @@ if __name__ == '__main__': # Ref: https://getridbug.com/python/how-to-set-environment-variables-in-github-actions-using-python/ with open(os.getenv('GITHUB_ENV'), 'a') as env_file: - # Construct tag string tags = ",".join([f"inventree/inventree:{tag}" for tag in docker_tags]) diff --git a/docs/docs/hooks.py b/docs/docs/hooks.py index fdd6f97464..9e4a5c4d97 100644 --- a/docs/docs/hooks.py +++ b/docs/docs/hooks.py @@ -39,11 +39,7 @@ def fetch_rtd_versions(): if version == 'latest': continue - versions.append({ - 'version': version, - 'title': version, - 'aliases': aliases, - }) + versions.append({'version': version, 'title': version, 'aliases': aliases}) if data['next']: make_request(data['next'], headers) @@ -62,11 +58,14 @@ def fetch_rtd_versions(): # Add "latest" version first if not any((x['title'] == 'latest' for x in versions)): - versions.insert(0, { - 'title': 'Development', - 'version': 'latest', - 'aliases': ['main', 'latest', 'development',], - }) + versions.insert( + 0, + { + 'title': 'Development', + 'version': 'latest', + 'aliases': ['main', 'latest', 'development'], + }, + ) # Ensure we have the 'latest' version current_version = os.environ.get('READTHEDOCS_VERSION', None) @@ -206,7 +205,6 @@ def on_config(config, *args, **kwargs): releases = [] for item in release_data: - # Ignore draft releases if item['draft']: continue @@ -221,11 +219,7 @@ def on_config(config, *args, **kwargs): continue # Check if there is a local file with release information - local_path = os.path.join( - os.path.dirname(__file__), - 'releases', - f'{tag}.md', - ) + local_path = os.path.join(os.path.dirname(__file__), 'releases', f'{tag}.md') if os.path.exists(local_path): item['local_path'] = local_path diff --git a/docs/main.py b/docs/main.py index 8f7f59c36d..b99988f061 100644 --- a/docs/main.py +++ b/docs/main.py @@ -15,13 +15,9 @@ def define_env(env): assets = [] - allowed = [ - '.png', - '.jpg', - ] + allowed = ['.png', '.jpg'] for asset in os.listdir(directory): - if any(asset.endswith(x) for x in allowed): assets.append(os.path.join(subdir, asset)) diff --git a/tasks.py b/tasks.py index 4558117b6c..63ad43ee04 100644 --- a/tasks.py +++ b/tasks.py @@ -113,10 +113,10 @@ def manage(c, cmd, pty: bool = False): cmd: Django command to run. pty (bool, optional): Run an interactive session. Defaults to False. """ - c.run('cd "{path}" && python3 manage.py {cmd}'.format( - path=managePyDir(), - cmd=cmd - ), pty=pty) + c.run( + 'cd "{path}" && python3 manage.py {cmd}'.format(path=managePyDir(), cmd=cmd), + pty=pty, + ) def yarn(c, cmd, pty: bool = False): @@ -133,6 +133,7 @@ def yarn(c, cmd, pty: bool = False): def node_available(versions: bool = False, bypass_yarn: bool = False): """Checks if the frontend environment (ie node and yarn in bash) is available.""" + def ret(val, val0=None, val1=None): if versions: return val, val0, val1 @@ -140,7 +141,10 @@ def node_available(versions: bool = False, bypass_yarn: bool = False): def check(cmd): try: - return str(subprocess.check_output([cmd], stderr=subprocess.STDOUT, shell=True), encoding='utf-8').strip() + return str( + subprocess.check_output([cmd], stderr=subprocess.STDOUT, shell=True), + encoding='utf-8', + ).strip() except subprocess.CalledProcessError: return None except FileNotFoundError: @@ -154,7 +158,9 @@ def node_available(versions: bool = False, bypass_yarn: bool = False): # Print a warning if node is available but yarn is not if node_version and not yarn_passes: - print('Node is available but yarn is not. Install yarn if you wish to build the frontend.') + print( + 'Node is available but yarn is not. Install yarn if you wish to build the frontend.' + ) # Return the result return ret(yarn_passes and node_version, node_version, yarn_version) @@ -168,7 +174,9 @@ def check_file_existance(filename: str, overwrite: bool = False): overwrite (bool, optional): Overwrite the file without asking. Defaults to False. """ if Path(filename).is_file() and overwrite is False: - response = input("Warning: file already exists. Do you want to overwrite? [y/N]: ") + response = input( + "Warning: file already exists. Do you want to overwrite? [y/N]: " + ) response = str(response).strip().lower() if response not in ['y', 'yes']: @@ -198,7 +206,9 @@ def install(c): # Install required Python packages with PIP c.run('pip3 install --upgrade pip') c.run('pip3 install --upgrade setuptools') - c.run('pip3 install --no-cache-dir --disable-pip-version-check -U -r requirements.txt') + c.run( + 'pip3 install --no-cache-dir --disable-pip-version-check -U -r requirements.txt' + ) @task(help={'tests': 'Set up test dataset at the end'}) @@ -256,11 +266,7 @@ def remove_mfa(c, mail=''): manage(c, f"remove_mfa {mail}") -@task( - help={ - 'frontend': 'Build the frontend', - } -) +@task(help={'frontend': 'Build the frontend'}) def static(c, frontend=False): """Copies required static files to the STATIC_ROOT directory, as per Django requirements.""" manage(c, "prerender") @@ -347,7 +353,7 @@ def migrate(c): help={ 'skip_backup': 'Skip database backup step (advanced users)', 'frontend': 'Force frontend compilation/download step (ignores INVENTREE_DOCKER)', - } + }, ) def update(c, skip_backup=False, frontend: bool = False): """Update InvenTree installation. @@ -390,13 +396,21 @@ def update(c, skip_backup=False, frontend: bool = False): # Data tasks -@task(help={ - 'filename': "Output filename (default = 'data.json')", - 'overwrite': "Overwrite existing files without asking first (default = off/False)", - 'include_permissions': "Include user and group permissions in the output file (filename) (default = off/False)", - 'delete_temp': "Delete temporary files (containing permissions) at end of run. Note that this will delete temporary files from previous runs as well. (default = off/False)" -}) -def export_records(c, filename='data.json', overwrite=False, include_permissions=False, delete_temp=False): +@task( + help={ + 'filename': "Output filename (default = 'data.json')", + 'overwrite': "Overwrite existing files without asking first (default = off/False)", + 'include_permissions': "Include user and group permissions in the output file (filename) (default = off/False)", + 'delete_temp': "Delete temporary files (containing permissions) at end of run. Note that this will delete temporary files from previous runs as well. (default = off/False)", + } +) +def export_records( + c, + filename='data.json', + overwrite=False, + include_permissions=False, + delete_temp=False, +): """Export all database records to a file. Write data to the file defined by filename. @@ -438,7 +452,6 @@ def export_records(c, filename='data.json', overwrite=False, include_permissions if include_permissions is False: for entry in data: if "model" in entry: - # Clear out any permissions specified for a group if entry["model"] == "auth.group": entry["fields"]["permissions"] = [] @@ -458,7 +471,10 @@ def export_records(c, filename='data.json', overwrite=False, include_permissions os.remove(tmpfile) -@task(help={'filename': 'Input filename', 'clear': 'Clear existing data before import'}, post=[rebuild_models, rebuild_thumbnails]) +@task( + help={'filename': 'Input filename', 'clear': 'Clear existing data before import'}, + post=[rebuild_models, rebuild_thumbnails], +) def import_records(c, filename='data.json', clear=False): """Import database records from a file.""" # Get an absolute path to the supplied filename @@ -482,7 +498,6 @@ def import_records(c, filename='data.json', clear=False): for entry in data: if "model" in entry: - # Clear out any permissions specified for a group if entry["model"] == "auth.group": entry["fields"]["permissions"] = [] @@ -530,32 +545,26 @@ def import_fixtures(c): fixtures = [ # Build model 'build', - # Common models 'settings', - # Company model 'company', 'price_breaks', 'supplier_part', - # Order model 'order', - # Part model 'bom', 'category', 'params', 'part', 'test_templates', - # Stock model 'location', 'stock_tests', 'stock', - # Users - 'users' + 'users', ] command = 'loaddata ' + ' '.join(fixtures) @@ -620,10 +629,10 @@ def test_translations(c): # compile regex reg = re.compile( - r"[a-zA-Z0-9]{1}" + # match any single letter and number # noqa: W504 - r"(?![^{\(\<]*[}\)\>])" + # that is not inside curly brackets, brackets or a tag # noqa: W504 - r"(?])" # that is not inside curly brackets, brackets or a tag # noqa: W504 + + r"(? start append + last_string = ( + last_string + line + ) # a new translatable string starts -> start append file_new.write(line) else: if last_string: - last_string = last_string + line # a string is being read in -> continue appending + last_string = ( + last_string + line + ) # a string is being read in -> continue appending file_new.write(line) # change out translation files @@ -668,7 +681,9 @@ def test_translations(c): 'coverage': 'Run code coverage analysis (requires coverage package)', } ) -def test(c, disable_pty=False, runtest='', migrations=False, report=False, coverage=False): +def test( + c, disable_pty=False, runtest='', migrations=False, report=False, coverage=False +): """Run unit-tests for InvenTree codebase. To run only certain test, use the argument --runtest. @@ -753,10 +768,12 @@ def setup_test(c, ignore_update=False, dev=False, path="inventree-demo-dataset") setup_dev(c) -@task(help={ - 'filename': "Output filename (default = 'schema.yml')", - 'overwrite': "Overwrite existing files without asking first (default = off/False)", -}) +@task( + help={ + 'filename': "Output filename (default = 'schema.yml')", + 'overwrite': "Overwrite existing files without asking first (default = off/False)", + } +) def schema(c, filename='schema.yml', overwrite=False): """Export current API schema.""" check_file_existance(filename, overwrite) @@ -767,13 +784,17 @@ def schema(c, filename='schema.yml', overwrite=False): def version(c): """Show the current version of InvenTree.""" import InvenTree.InvenTree.version as InvenTreeVersion - from InvenTree.InvenTree.config import (get_config_file, get_media_dir, - get_static_dir) + from InvenTree.InvenTree.config import ( + get_config_file, + get_media_dir, + get_static_dir, + ) # Gather frontend version information _, node, yarn = node_available(versions=True) - print(f""" + print( + f""" InvenTree - inventree.org The Open-Source Inventory Management System\n @@ -792,13 +813,16 @@ Node {node if node else 'N/A'} Yarn {yarn if yarn else 'N/A'} Commit hash:{InvenTreeVersion.inventreeCommitHash()} -Commit date:{InvenTreeVersion.inventreeCommitDate()}""") +Commit date:{InvenTreeVersion.inventreeCommitDate()}""" + ) if len(sys.argv) == 1 and sys.argv[0].startswith('/opt/inventree/env/lib/python'): - print(""" + print( + """ You are probably running the package installer / single-line installer. Please mentioned that in any bug reports! Use '--list' for a list of available commands -Use '--help' for help on a specific command""") +Use '--help' for help on a specific command""" + ) @task() @@ -864,15 +888,25 @@ def frontend_dev(c): yarn(c, "yarn run dev") -@task(help={ - 'ref': "git ref, default: current git ref", - 'tag': "git tag to look for release", - 'file': "destination to frontend-build.zip file", - 'repo': "GitHub repository, default: InvenTree/inventree", - 'extract': "Also extract and place at the correct destination, default: True", - 'clean': "Delete old files from InvenTree/web/static/web first, default: True", -}) -def frontend_download(c, ref=None, tag=None, file=None, repo="InvenTree/inventree", extract=True, clean=True): +@task( + help={ + 'ref': "git ref, default: current git ref", + 'tag': "git tag to look for release", + 'file': "destination to frontend-build.zip file", + 'repo': "GitHub repository, default: InvenTree/inventree", + 'extract': "Also extract and place at the correct destination, default: True", + 'clean': "Delete old files from InvenTree/web/static/web first, default: True", + } +) +def frontend_download( + c, + ref=None, + tag=None, + file=None, + repo="InvenTree/inventree", + extract=True, + clean=True, +): """Download a pre-build frontend from GitHub if you dont want to install nodejs on your machine. There are 3 possibilities to install the frontend: @@ -924,11 +958,15 @@ def frontend_download(c, ref=None, tag=None, file=None, repo="InvenTree/inventre def handle_download(url): # download frontend-build.zip to temporary file - with requests.get(url, headers=default_headers, stream=True, allow_redirects=True) as response, NamedTemporaryFile(suffix=".zip") as dst: + with requests.get( + url, headers=default_headers, stream=True, allow_redirects=True + ) as response, NamedTemporaryFile(suffix=".zip") as dst: response.raise_for_status() # auto decode the gzipped raw data - response.raw.read = functools.partial(response.raw.read, decode_content=True) + response.raw.read = functools.partial( + response.raw.read, decode_content=True + ) with open(dst.name, "wb") as f: shutil.copyfileobj(response.raw, f) print(f"Downloaded frontend build to temporary file: {dst.name}") @@ -947,7 +985,9 @@ def frontend_download(c, ref=None, tag=None, file=None, repo="InvenTree/inventre if ref is None and tag is None: try: - ref = subprocess.check_output(["git", "rev-parse", "HEAD"], encoding="utf-8").strip() + ref = subprocess.check_output( + ["git", "rev-parse", "HEAD"], encoding="utf-8" + ).strip() except Exception: print("[ERROR] Cannot get current ref via 'git rev-parse HEAD'") return @@ -958,35 +998,54 @@ def frontend_download(c, ref=None, tag=None, file=None, repo="InvenTree/inventre if tag: tag = tag.lstrip("v") try: - handle_download(f"https://github.com/{repo}/releases/download/{tag}/frontend-build.zip") + handle_download( + f"https://github.com/{repo}/releases/download/{tag}/frontend-build.zip" + ) except Exception as e: if not isinstance(e, requests.HTTPError): raise e - print(f"""[ERROR] An Error occurred. Unable to download frontend build, release or build does not exist, + print( + f"""[ERROR] An Error occurred. Unable to download frontend build, release or build does not exist, try downloading the frontend-build.zip yourself via: https://github.com/{repo}/releases -Then try continuing by running: invoke frontend-download --file """) +Then try continuing by running: invoke frontend-download --file """ + ) return if ref: # get workflow run from all workflow runs on that particular ref - workflow_runs = requests.get(f"https://api.github.com/repos/{repo}/actions/runs?head_sha={ref}", headers=default_headers).json() + workflow_runs = requests.get( + f"https://api.github.com/repos/{repo}/actions/runs?head_sha={ref}", + headers=default_headers, + ).json() if not (qc_run := find_resource(workflow_runs["workflow_runs"], "name", "QC")): print("[ERROR] Cannot find any workflow runs for current sha") return - print(f"Found workflow {qc_run['name']} (run {qc_run['run_number']}-{qc_run['run_attempt']})") + print( + f"Found workflow {qc_run['name']} (run {qc_run['run_number']}-{qc_run['run_attempt']})" + ) # get frontend-build artifact from all artifacts available for this workflow run - artifacts = requests.get(qc_run["artifacts_url"], headers=default_headers).json() - if not (frontend_artifact := find_resource(artifacts["artifacts"], "name", "frontend-build")): + artifacts = requests.get( + qc_run["artifacts_url"], headers=default_headers + ).json() + if not ( + frontend_artifact := find_resource( + artifacts["artifacts"], "name", "frontend-build" + ) + ): print("[ERROR] Cannot find frontend-build.zip attachment for current sha") return - print(f"Found artifact {frontend_artifact['name']} with id {frontend_artifact['id']} ({frontend_artifact['size_in_bytes']/1e6:.2f}MB).") + print( + f"Found artifact {frontend_artifact['name']} with id {frontend_artifact['id']} ({frontend_artifact['size_in_bytes']/1e6:.2f}MB)." + ) - print(f""" + print( + f""" GitHub doesn't allow artifact downloads from anonymous users. Either download the following file via your signed in browser, or consider using a point release download via invoke frontend-download --tag Download: https://github.com/{repo}/suites/{qc_run['check_suite_id']}/artifacts/{frontend_artifact['id']} manually and - continue by running: invoke frontend-download --file """) + continue by running: invoke frontend-download --file """ + )