From fe68dc73185cde3ee86bb85c1fe52c4f85e5ffd2 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 24 Dec 2024 21:16:24 +0100 Subject: [PATCH] Refactor fix formatting exclusion (#8746) * fix ruff exclusions * aut-format * Fix docstrings * more fixes * ignore error(s) * fix imports * adjust descriptions for build --- pyproject.toml | 2 - .../InvenTree/InvenTree/api_version.py | 5 +- src/backend/InvenTree/build/admin.py | 59 +- src/backend/InvenTree/build/api.py | 327 ++++---- src/backend/InvenTree/build/apps.py | 5 +- src/backend/InvenTree/build/filters.py | 10 +- src/backend/InvenTree/build/models.py | 599 +++++++------- src/backend/InvenTree/build/serializers.py | 777 ++++++++++-------- src/backend/InvenTree/build/status_codes.py | 4 +- src/backend/InvenTree/build/tasks.py | 89 +- src/backend/InvenTree/build/test_api.py | 603 ++++---------- src/backend/InvenTree/build/test_build.py | 412 ++++------ .../InvenTree/build/test_migrations.py | 106 +-- src/backend/InvenTree/build/tests.py | 46 +- src/backend/InvenTree/build/validators.py | 6 +- 15 files changed, 1375 insertions(+), 1675 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fc10ce92ed..6f37c4efaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,8 +3,6 @@ exclude = [ ".git", "__pycache__", - "dist", - "build", "test.py", "tests", "venv", diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 563cb55980..91d803b28d 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,13 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 294 +INVENTREE_API_VERSION = 295 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v295 - 2024-12-23 : https://github.com/inventree/InvenTree/pull/8746 + - Improve API documentation for build APIs + v294 - 2024-12-23 : https://github.com/inventree/InvenTree/pull/8738 - Extends registration API documentation diff --git a/src/backend/InvenTree/build/admin.py b/src/backend/InvenTree/build/admin.py index a166700fb6..c6732109a3 100644 --- a/src/backend/InvenTree/build/admin.py +++ b/src/backend/InvenTree/build/admin.py @@ -1,71 +1,36 @@ -"""Admin functionality for the BuildOrder app""" +"""Admin functionality for the BuildOrder app.""" from django.contrib import admin -from build.models import Build, BuildLine, BuildItem +from build.models import Build, BuildItem, BuildLine @admin.register(Build) class BuildAdmin(admin.ModelAdmin): - """Class for managing the Build model via the admin interface""" + """Class for managing the Build model via the admin interface.""" - exclude = [ - 'reference_int', - ] + exclude = ['reference_int'] - list_display = ( - 'reference', - 'title', - 'part', - 'status', - 'batch', - 'quantity', - ) + list_display = ('reference', 'title', 'part', 'status', 'batch', 'quantity') - search_fields = [ - 'reference', - 'title', - 'part__name', - 'part__description', - ] + search_fields = ['reference', 'title', 'part__name', 'part__description'] - autocomplete_fields = [ - 'parent', - 'part', - 'sales_order', - 'take_from', - 'destination', - ] + autocomplete_fields = ['parent', 'part', 'sales_order', 'take_from', 'destination'] @admin.register(BuildItem) class BuildItemAdmin(admin.ModelAdmin): """Class for managing the BuildItem model via the admin interface.""" - list_display = ( - 'stock_item', - 'quantity' - ) + list_display = ('stock_item', 'quantity') - autocomplete_fields = [ - 'build_line', - 'stock_item', - 'install_into', - ] + autocomplete_fields = ['build_line', 'stock_item', 'install_into'] @admin.register(BuildLine) class BuildLineAdmin(admin.ModelAdmin): - """Class for managing the BuildLine model via the admin interface""" + """Class for managing the BuildLine model via the admin interface.""" - list_display = ( - 'build', - 'bom_item', - 'quantity', - ) + list_display = ('build', 'bom_item', 'quantity') - search_fields = [ - 'build__title', - 'build__reference', - 'bom_item__sub_part__name', - ] + search_fields = ['build__title', 'build__reference', 'bom_item__sub_part__name'] diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index 0eeb17ed61..8623bd8e8c 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -1,29 +1,28 @@ """JSON API for the Build app.""" from __future__ import annotations + +from django.contrib.auth.models import User from django.db.models import F, Q from django.urls import include, path from django.utils.translation import gettext_lazy as _ -from django.contrib.auth.models import User from django_filters import rest_framework as rest_filters from rest_framework.exceptions import ValidationError -from importer.mixins import DataExportViewMixin - -from InvenTree.api import BulkDeleteMixin, MetadataView -from generic.states.api import StatusView -from InvenTree.helpers import str2bool, isNull -from build.status_codes import BuildStatus, BuildStatusGroups -from InvenTree.mixins import CreateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI - -import common.models import build.admin import build.serializers -from build.models import Build, BuildLine, BuildItem +import common.models import part.models +from build.models import Build, BuildItem, BuildLine +from build.status_codes import BuildStatus, BuildStatusGroups +from generic.states.api import StatusView +from importer.mixins import DataExportViewMixin +from InvenTree.api import BulkDeleteMixin, MetadataView +from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS, InvenTreeDateFilter +from InvenTree.helpers import isNull, str2bool +from InvenTree.mixins import CreateAPI, ListCreateAPI, RetrieveUpdateDestroyAPI from users.models import Owner -from InvenTree.filters import InvenTreeDateFilter, SEARCH_ORDER_FILTER_ALIAS class BuildFilter(rest_filters.FilterSet): @@ -31,17 +30,18 @@ class BuildFilter(rest_filters.FilterSet): class Meta: """Metaclass options.""" + model = Build - fields = [ - 'sales_order', - ] + fields = ['sales_order'] status = rest_filters.NumberFilter(label='Status') active = rest_filters.BooleanFilter(label='Build is active', method='filter_active') # 'outstanding' is an alias for 'active' here - outstanding = rest_filters.BooleanFilter(label='Build is outstanding', method='filter_active') + outstanding = rest_filters.BooleanFilter( + label='Build is outstanding', method='filter_active' + ) def filter_active(self, queryset, name, value): """Filter the queryset to either include or exclude orders which are active.""" @@ -50,12 +50,12 @@ class BuildFilter(rest_filters.FilterSet): return queryset.exclude(status__in=BuildStatusGroups.ACTIVE_CODES) parent = rest_filters.ModelChoiceFilter( - queryset=Build.objects.all(), - label=_('Parent Build'), - field_name='parent', + queryset=Build.objects.all(), label=_('Parent Build'), field_name='parent' ) - include_variants = rest_filters.BooleanFilter(label=_('Include Variants'), method='filter_include_variants') + include_variants = rest_filters.BooleanFilter( + label=_('Include Variants'), method='filter_include_variants' + ) def filter_include_variants(self, queryset, name, value): """Filter by whether or not to include variants of the selected part. @@ -64,13 +64,10 @@ class BuildFilter(rest_filters.FilterSet): - This filter does nothing by itself, and requires the 'part' filter to be set. - Refer to the 'filter_part' method for more information. """ - return queryset part = rest_filters.ModelChoiceFilter( - queryset=part.models.Part.objects.all(), - field_name='part', - method='filter_part' + queryset=part.models.Part.objects.all(), field_name='part', method='filter_part' ) def filter_part(self, queryset, name, part): @@ -80,7 +77,6 @@ class BuildFilter(rest_filters.FilterSet): - If "include_variants" is True, include all variants of the selected part. - Otherwise, just filter by the selected part. """ - include_variants = str2bool(self.data.get('include_variants', False)) if include_variants: @@ -91,16 +87,17 @@ class BuildFilter(rest_filters.FilterSet): ancestor = rest_filters.ModelChoiceFilter( queryset=Build.objects.all(), label=_('Ancestor Build'), - method='filter_ancestor' + method='filter_ancestor', ) def filter_ancestor(self, queryset, name, parent): """Filter by 'parent' build order.""" - builds = parent.get_descendants(include_self=False) return queryset.filter(pk__in=[b.pk for b in builds]) - overdue = rest_filters.BooleanFilter(label='Build is overdue', method='filter_overdue') + overdue = rest_filters.BooleanFilter( + label='Build is overdue', method='filter_overdue' + ) def filter_overdue(self, queryset, name, value): """Filter the queryset to either include or exclude orders which are overdue.""" @@ -109,8 +106,7 @@ class BuildFilter(rest_filters.FilterSet): return queryset.exclude(Build.OVERDUE_FILTER) assigned_to_me = rest_filters.BooleanFilter( - label=_('Assigned to me'), - method='filter_assigned_to_me' + label=_('Assigned to me'), method='filter_assigned_to_me' ) def filter_assigned_to_me(self, queryset, name, value): @@ -125,14 +121,11 @@ class BuildFilter(rest_filters.FilterSet): return queryset.exclude(responsible__in=owners) issued_by = rest_filters.ModelChoiceFilter( - queryset=Owner.objects.all(), - label=_('Issued By'), - method='filter_issued_by' + queryset=Owner.objects.all(), label=_('Issued By'), method='filter_issued_by' ) def filter_issued_by(self, queryset, name, owner): """Filter by 'owner' which issued the order.""" - if owner.label() == 'user': user = User.objects.get(pk=owner.owner_id) return queryset.filter(issued_by=user) @@ -143,70 +136,62 @@ class BuildFilter(rest_filters.FilterSet): return queryset.none() assigned_to = rest_filters.ModelChoiceFilter( - queryset=Owner.objects.all(), - field_name='responsible', - label=_('Assigned To') + queryset=Owner.objects.all(), field_name='responsible', label=_('Assigned To') ) def filter_responsible(self, queryset, name, owner): """Filter by orders which are assigned to the specified owner.""" - owners = list(Owner.objects.filter(pk=owner)) # if we query by a user, also find all ownerships through group memberships if len(owners) > 0 and owners[0].label() == 'user': - owners = Owner.get_owners_matching_user(User.objects.get(pk=owners[0].owner_id)) + owners = Owner.get_owners_matching_user( + User.objects.get(pk=owners[0].owner_id) + ) return queryset.filter(responsible__in=owners) # 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' ) 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""" + """Filter by whether or not the order has a project code.""" if str2bool(value): return queryset.exclude(project_code=None) return queryset.filter(project_code=None) created_before = InvenTreeDateFilter( - label=_('Created before'), - field_name='creation_date', lookup_expr='lt'\ + label=_('Created before'), field_name='creation_date', lookup_expr='lt' ) created_after = InvenTreeDateFilter( - label=_('Created after'), - field_name='creation_date', lookup_expr='gt' + label=_('Created after'), field_name='creation_date', lookup_expr='gt' ) target_date_before = InvenTreeDateFilter( - label=_('Target date before'), - field_name='target_date', lookup_expr='lt' + label=_('Target date before'), field_name='target_date', lookup_expr='lt' ) target_date_after = InvenTreeDateFilter( - label=_('Target date after'), - field_name='target_date', lookup_expr='gt' + label=_('Target date after'), field_name='target_date', lookup_expr='gt' ) completed_before = InvenTreeDateFilter( - label=_('Completed before'), - field_name='completion_date', lookup_expr='lt' + label=_('Completed before'), field_name='completion_date', lookup_expr='lt' ) completed_after = InvenTreeDateFilter( - label=_('Completed after'), - field_name='completion_date', lookup_expr='gt' + label=_('Completed after'), field_name='completion_date', lookup_expr='gt' ) @@ -227,7 +212,7 @@ class BuildMixin: 'build_lines__bom_item', 'build_lines__build', 'part', - 'part__pricing_data' + 'part__pricing_data', ) return queryset @@ -295,7 +280,6 @@ class BuildList(DataExportViewMixin, BuildMixin, ListCreateAPI): exclude_tree = params.get('exclude_tree', None) if exclude_tree is not None: - try: build = Build.objects.get(pk=exclude_tree) @@ -332,12 +316,14 @@ class BuildDetail(BuildMixin, RetrieveUpdateDestroyAPI): """API endpoint for detail view of a Build object.""" def destroy(self, request, *args, **kwargs): - """Only allow deletion of a BuildOrder if the build status is CANCELLED""" + """Only allow deletion of a BuildOrder if the build status is CANCELLED.""" build = self.get_object() if build.status != BuildStatus.CANCELLED: raise ValidationError({ - "non_field_errors": [_("Build must be cancelled before it can be deleted")] + 'non_field_errors': [ + _('Build must be cancelled before it can be deleted') + ] }) return super().destroy(request, *args, **kwargs) @@ -374,18 +360,26 @@ class BuildLineFilter(rest_filters.FilterSet): class Meta: """Meta information for the BuildLineFilter class.""" + model = BuildLine - fields = [ - 'build', - 'bom_item', - ] + fields = ['build', 'bom_item'] # Fields on related models - consumable = rest_filters.BooleanFilter(label=_('Consumable'), field_name='bom_item__consumable') - optional = rest_filters.BooleanFilter(label=_('Optional'), field_name='bom_item__optional') - assembly = rest_filters.BooleanFilter(label=_('Assembly'), field_name='bom_item__sub_part__assembly') - tracked = rest_filters.BooleanFilter(label=_('Tracked'), field_name='bom_item__sub_part__trackable') - testable = rest_filters.BooleanFilter(label=_('Testable'), field_name='bom_item__sub_part__testable') + consumable = rest_filters.BooleanFilter( + label=_('Consumable'), field_name='bom_item__consumable' + ) + optional = rest_filters.BooleanFilter( + label=_('Optional'), field_name='bom_item__optional' + ) + assembly = rest_filters.BooleanFilter( + label=_('Assembly'), field_name='bom_item__sub_part__assembly' + ) + tracked = rest_filters.BooleanFilter( + label=_('Tracked'), field_name='bom_item__sub_part__trackable' + ) + testable = rest_filters.BooleanFilter( + label=_('Testable'), field_name='bom_item__sub_part__testable' + ) part = rest_filters.ModelChoiceFilter( queryset=part.models.Part.objects.all(), @@ -394,8 +388,7 @@ class BuildLineFilter(rest_filters.FilterSet): ) order_outstanding = rest_filters.BooleanFilter( - label=_('Order Outstanding'), - method='filter_order_outstanding' + label=_('Order Outstanding'), method='filter_order_outstanding' ) def filter_order_outstanding(self, queryset, name, value): @@ -404,18 +397,22 @@ class BuildLineFilter(rest_filters.FilterSet): return queryset.filter(build__status__in=BuildStatusGroups.ACTIVE_CODES) return queryset.exclude(build__status__in=BuildStatusGroups.ACTIVE_CODES) - allocated = rest_filters.BooleanFilter(label=_('Allocated'), method='filter_allocated') + allocated = rest_filters.BooleanFilter( + label=_('Allocated'), method='filter_allocated' + ) def filter_allocated(self, queryset, name, value): - """Filter by whether each BuildLine is fully allocated""" + """Filter by whether each BuildLine is fully allocated.""" if str2bool(value): return queryset.filter(allocated__gte=F('quantity')) return queryset.filter(allocated__lt=F('quantity')) - available = rest_filters.BooleanFilter(label=_('Available'), method='filter_available') + available = rest_filters.BooleanFilter( + label=_('Available'), method='filter_available' + ) def filter_available(self, queryset, name, value): - """Filter by whether there is sufficient stock available for each BuildLine: + """Filter by whether there is sufficient stock available for each BuildLine. To determine this, we need to know: @@ -423,14 +420,18 @@ class BuildLineFilter(rest_filters.FilterSet): - The quantity available for each BuildLine (including variants and substitutes) - The quantity allocated for each BuildLine """ - flt = Q(quantity__lte=F('allocated') + F('available_stock') + F('available_substitute_stock') + F('available_variant_stock')) + flt = Q( + quantity__lte=F('allocated') + + F('available_stock') + + F('available_substitute_stock') + + F('available_variant_stock') + ) if str2bool(value): return queryset.filter(flt) return queryset.exclude(flt) - class BuildLineEndpoint: """Mixin class for BuildLine API endpoints.""" @@ -439,7 +440,6 @@ class BuildLineEndpoint: def get_serializer(self, *args, **kwargs): """Return the serializer instance for this endpoint.""" - kwargs['context'] = self.get_serializer_context() try: @@ -460,10 +460,12 @@ class BuildLineEndpoint: - If this is a "detail" view, use the build associated with the line - If this is a "list" view, use the build associated with the request """ - raise NotImplementedError("get_source_build must be implemented in the child class") + raise NotImplementedError( + 'get_source_build must be implemented in the child class' + ) def get_queryset(self): - """Override queryset to select-related and annotate""" + """Override queryset to select-related and annotate.""" queryset = super().get_queryset() if not hasattr(self, 'source_build'): @@ -471,11 +473,13 @@ class BuildLineEndpoint: source_build = self.source_build - return build.serializers.BuildLineSerializer.annotate_queryset(queryset, build=source_build) + return build.serializers.BuildLineSerializer.annotate_queryset( + queryset, build=source_build + ) class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI): - """API endpoint for accessing a list of BuildLine objects""" + """API endpoint for accessing a list of BuildLine objects.""" filterset_class = BuildLineFilter filter_backends = SEARCH_ORDER_FILTER_ALIAS @@ -514,7 +518,6 @@ class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI): def get_source_build(self) -> Build | None: """Return the target build for the BuildLine queryset.""" - source_build = None try: @@ -532,7 +535,6 @@ class BuildLineDetail(BuildLineEndpoint, RetrieveUpdateDestroyAPI): def get_source_build(self) -> Build | None: """Return the target source location for the BuildLine queryset.""" - return None @@ -607,12 +609,8 @@ class BuildFinish(BuildOrderContextMixin, CreateAPI): def get_queryset(self): """Return the queryset for the BuildFinish API endpoint.""" - queryset = super().get_queryset() - queryset = queryset.prefetch_related( - 'build_lines', - 'build_lines__allocations' - ) + queryset = queryset.prefetch_related('build_lines', 'build_lines__allocations') return queryset @@ -658,6 +656,7 @@ class BuildHold(BuildOrderContextMixin, CreateAPI): queryset = Build.objects.all() serializer_class = build.serializers.BuildHoldSerializer + class BuildCancel(BuildOrderContextMixin, CreateAPI): """API endpoint for cancelling a BuildOrder.""" @@ -673,16 +672,13 @@ class BuildItemDetail(RetrieveUpdateDestroyAPI): class BuildItemFilter(rest_filters.FilterSet): - """Custom filterset for the BuildItemList API endpoint""" + """Custom filterset for the BuildItemList API endpoint.""" class Meta: - """Metaclass option""" + """Metaclass option.""" + model = BuildItem - fields = [ - 'build_line', - 'stock_item', - 'install_into', - ] + fields = ['build_line', 'stock_item', 'install_into'] include_variants = rest_filters.BooleanFilter( label=_('Include Variants'), method='filter_include_variants' @@ -695,7 +691,6 @@ class BuildItemFilter(rest_filters.FilterSet): - This filter does nothing by itself, and requires the 'part' filter to be set. - Refer to the 'filter_part' method for more information. """ - return queryset part = rest_filters.ModelChoiceFilter( @@ -712,11 +707,12 @@ class BuildItemFilter(rest_filters.FilterSet): - If "include_variants" is True, include all variants of the selected part. - Otherwise, just filter by the selected part. """ - include_variants = str2bool(self.data.get('include_variants', False)) if include_variants: - return queryset.filter(stock_item__part__in=part.get_descendants(include_self=True)) + return queryset.filter( + stock_item__part__in=part.get_descendants(include_self=True) + ) else: return queryset.filter(stock_item__part=part) @@ -729,7 +725,7 @@ class BuildItemFilter(rest_filters.FilterSet): tracked = rest_filters.BooleanFilter(label='Tracked', method='filter_tracked') def filter_tracked(self, queryset, name, value): - """Filter the queryset based on whether build items are tracked""" + """Filter the queryset based on whether build items are tracked.""" if str2bool(value): return queryset.exclude(install_into=None) return queryset.filter(install_into=None) @@ -752,7 +748,12 @@ class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI): try: params = self.request.query_params - for key in ['part_detail', 'location_detail', 'stock_detail', 'build_detail']: + for key in [ + 'part_detail', + 'location_detail', + 'stock_detail', + 'build_detail', + ]: if key in params: kwargs[key] = str2bool(params.get(key, False)) except AttributeError: @@ -778,9 +779,7 @@ class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI): 'stock_item__supplier_part__supplier', 'stock_item__supplier_part__manufacturer_part', 'stock_item__supplier_part__manufacturer_part__manufacturer', - ).prefetch_related( - 'stock_item__location__tags', - ) + ).prefetch_related('stock_item__location__tags') return queryset @@ -794,7 +793,6 @@ class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI): output = params.get('output', None) if output: - if isNull(output): queryset = queryset.filter(install_into=None) else: @@ -802,14 +800,7 @@ class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI): return queryset - ordering_fields = [ - 'part', - 'sku', - 'quantity', - 'location', - 'reference', - 'IPN', - ] + ordering_fields = ['part', 'sku', 'quantity', 'location', 'reference', 'IPN'] ordering_field_aliases = { 'part': 'stock_item__part__name', @@ -828,42 +819,84 @@ class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI): build_api_urls = [ - # Build lines - path('line/', include([ - path('/', BuildLineDetail.as_view(), name='api-build-line-detail'), - path('', BuildLineList.as_view(), name='api-build-line-list'), - ])), - + path( + 'line/', + include([ + path('/', BuildLineDetail.as_view(), name='api-build-line-detail'), + path('', BuildLineList.as_view(), name='api-build-line-list'), + ]), + ), # Build Items - path('item/', include([ - path('/', include([ - path('metadata/', MetadataView.as_view(), {'model': BuildItem}, name='api-build-item-metadata'), - path('', BuildItemDetail.as_view(), name='api-build-item-detail'), - ])), - path('', BuildItemList.as_view(), name='api-build-item-list'), - ])), - + path( + 'item/', + include([ + path( + '/', + include([ + path( + 'metadata/', + MetadataView.as_view(), + {'model': BuildItem}, + name='api-build-item-metadata', + ), + path('', BuildItemDetail.as_view(), name='api-build-item-detail'), + ]), + ), + path('', BuildItemList.as_view(), name='api-build-item-list'), + ]), + ), # Build Detail - path('/', include([ - path('allocate/', BuildAllocate.as_view(), name='api-build-allocate'), - path('auto-allocate/', BuildAutoAllocate.as_view(), name='api-build-auto-allocate'), - path('complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'), - path('create-output/', BuildOutputCreate.as_view(), name='api-build-output-create'), - path('delete-outputs/', BuildOutputDelete.as_view(), name='api-build-output-delete'), - path('scrap-outputs/', BuildOutputScrap.as_view(), name='api-build-output-scrap'), - path('issue/', BuildIssue.as_view(), name='api-build-issue'), - path('hold/', BuildHold.as_view(), name='api-build-hold'), - path('finish/', BuildFinish.as_view(), name='api-build-finish'), - path('cancel/', BuildCancel.as_view(), name='api-build-cancel'), - path('unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'), - path('metadata/', MetadataView.as_view(), {'model': Build}, name='api-build-metadata'), - path('', BuildDetail.as_view(), name='api-build-detail'), - ])), - + path( + '/', + include([ + path('allocate/', BuildAllocate.as_view(), name='api-build-allocate'), + path( + 'auto-allocate/', + BuildAutoAllocate.as_view(), + name='api-build-auto-allocate', + ), + path( + 'complete/', + BuildOutputComplete.as_view(), + name='api-build-output-complete', + ), + path( + 'create-output/', + BuildOutputCreate.as_view(), + name='api-build-output-create', + ), + path( + 'delete-outputs/', + BuildOutputDelete.as_view(), + name='api-build-output-delete', + ), + path( + 'scrap-outputs/', + BuildOutputScrap.as_view(), + name='api-build-output-scrap', + ), + path('issue/', BuildIssue.as_view(), name='api-build-issue'), + path('hold/', BuildHold.as_view(), name='api-build-hold'), + path('finish/', BuildFinish.as_view(), name='api-build-finish'), + path('cancel/', BuildCancel.as_view(), name='api-build-cancel'), + path('unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'), + path( + 'metadata/', + MetadataView.as_view(), + {'model': Build}, + name='api-build-metadata', + ), + path('', BuildDetail.as_view(), name='api-build-detail'), + ]), + ), # Build order status code information - path('status/', StatusView.as_view(), {StatusView.MODEL_REF: BuildStatus}, name='api-build-status-codes'), - + path( + 'status/', + StatusView.as_view(), + {StatusView.MODEL_REF: BuildStatus}, + name='api-build-status-codes', + ), # Build List path('', BuildList.as_view(), name='api-build-list'), ] diff --git a/src/backend/InvenTree/build/apps.py b/src/backend/InvenTree/build/apps.py index 683e410b66..8a449b84db 100644 --- a/src/backend/InvenTree/build/apps.py +++ b/src/backend/InvenTree/build/apps.py @@ -1,8 +1,9 @@ -"""Django app for the BuildOrder module""" +"""Django app for the BuildOrder module.""" from django.apps import AppConfig class BuildConfig(AppConfig): - """BuildOrder app config class""" + """BuildOrder app config class.""" + name = 'build' diff --git a/src/backend/InvenTree/build/filters.py b/src/backend/InvenTree/build/filters.py index ff3c02a523..9b995d2de1 100644 --- a/src/backend/InvenTree/build/filters.py +++ b/src/backend/InvenTree/build/filters.py @@ -1,25 +1,21 @@ """Queryset filtering helper functions for the Build app.""" - from django.db import models -from django.db.models import Sum, Q +from django.db.models import Q, Sum from django.db.models.functions import Coalesce def annotate_allocated_quantity(queryset: Q) -> Q: - """ - Annotate the 'allocated' quantity for each build item in the queryset. + """Annotate the 'allocated' quantity for each build item in the queryset. Arguments: queryset: The BuildLine queryset to annotate """ - queryset = queryset.prefetch_related('allocations') return queryset.annotate( allocated=Coalesce( - Sum('allocations__quantity'), 0, - output_field=models.DecimalField() + Sum('allocations__quantity'), 0, output_field=models.DecimalField() ) ) diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index 4002c63fc3..236cfbdb85 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -3,50 +3,49 @@ import decimal import logging from datetime import datetime -from django.conf import settings from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator from django.db import models, transaction -from django.db.models import F, Sum, Q +from django.db.models import F, Q, Sum from django.db.models.functions import Coalesce from django.db.models.signals import post_save from django.dispatch.dispatcher import receiver from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from mptt.models import MPTTModel, TreeForeignKey from mptt.exceptions import InvalidMove - +from mptt.models import MPTTModel, TreeForeignKey from rest_framework import serializers -from build.status_codes import BuildStatus, BuildStatusGroups -from stock.status_codes import StockStatus, StockHistoryCode - -from build.events import BuildEvents -from build.filters import annotate_allocated_quantity -from build.validators import generate_next_build_reference, validate_build_order_reference -from generic.states import StateTransitionMixin - +import generic.states import InvenTree.fields import InvenTree.helpers import InvenTree.helpers_model import InvenTree.models import InvenTree.ready import InvenTree.tasks - -import common.models -from common.notifications import trigger_notification, InvenTreeNotificationBodies -from common.settings import get_global_setting -from plugin.events import trigger_event - import part.models import report.mixins import stock.models import users.models -import generic.states - +from build.events import BuildEvents +from build.filters import annotate_allocated_quantity +from build.status_codes import BuildStatus, BuildStatusGroups +from build.validators import ( + generate_next_build_reference, + validate_build_order_reference, +) +from common.models import ProjectCode +from common.notifications import InvenTreeNotificationBodies, trigger_notification +from common.settings import ( + get_global_setting, + prevent_build_output_complete_on_incompleted_tests, +) +from generic.states import StateTransitionMixin +from plugin.events import trigger_event +from stock.status_codes import StockHistoryCode, StockStatus logger = logging.getLogger('inventree') @@ -60,7 +59,8 @@ class Build( InvenTree.models.PluginValidationMixin, InvenTree.models.ReferenceIndexingMixin, StateTransitionMixin, - MPTTModel): + MPTTModel, +): """A Build object organises the creation of new StockItem objects from other existing StockItem objects. Attributes: @@ -85,34 +85,33 @@ class Build( """ class Meta: - """Metaclass options for the BuildOrder model""" - verbose_name = _("Build Order") - verbose_name_plural = _("Build Orders") + """Metaclass options for the BuildOrder model.""" - OVERDUE_FILTER = Q(status__in=BuildStatusGroups.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__lte=InvenTree.helpers.current_date()) + verbose_name = _('Build Order') + verbose_name_plural = _('Build Orders') + + OVERDUE_FILTER = ( + Q(status__in=BuildStatusGroups.ACTIVE_CODES) + & ~Q(target_date=None) + & Q(target_date__lte=InvenTree.helpers.current_date()) + ) # Global setting for specifying reference pattern REFERENCE_PATTERN_SETTING = 'BUILDORDER_REFERENCE_PATTERN' @staticmethod def get_api_url(): - """Return the API URL associated with the BuildOrder model""" + """Return the API URL associated with the BuildOrder model.""" return reverse('api-build-list') def api_instance_filters(self): - """Returns custom API filters for the particular BuildOrder instance""" - return { - 'parent': { - 'exclude_tree': self.pk, - } - } + """Returns custom API filters for the particular BuildOrder instance.""" + return {'parent': {'exclude_tree': self.pk}} @classmethod def api_defaults(cls, request=None): """Return default values for this model when issuing an API OPTIONS request.""" - defaults = { - 'reference': generate_next_build_reference(), - } + defaults = {'reference': generate_next_build_reference()} if request and request.user: defaults['issued_by'] = request.user.pk @@ -122,10 +121,10 @@ class Build( @classmethod def barcode_model_type_code(cls): """Return the associated barcode model type code for this model.""" - return "BO" + return 'BO' def save(self, *args, **kwargs): - """Custom save method for the BuildOrder model""" + """Custom save method for the BuildOrder model.""" self.reference_int = self.validate_reference_field(self.reference) # Check part when initially creating the build order @@ -153,7 +152,6 @@ class Build( # On first save (i.e. creation), run some extra checks if self.pk is None: - # Set the destination location (if not specified) if not self.destination: self.destination = self.part.get_default_location() @@ -161,13 +159,10 @@ class Build( try: super().save(*args, **kwargs) except InvalidMove: - raise ValidationError({ - 'parent': _('Invalid choice for parent build'), - }) + raise ValidationError({'parent': _('Invalid choice for parent build')}) def clean(self): - """Validate the BuildOrder model""" - + """Validate the BuildOrder model.""" super().clean() if get_global_setting('BUILDORDER_REQUIRE_RESPONSIBLE'): @@ -178,13 +173,10 @@ class Build( # Prevent changing target part after creation if self.has_field_changed('part'): - raise ValidationError({ - 'part': _('Build order part cannot be changed') - }) + raise ValidationError({'part': _('Build order part cannot be changed')}) def report_context(self) -> dict: """Generate custom report context data.""" - return { 'bom_items': self.part.get_bom_items(), 'build': self, @@ -193,10 +185,9 @@ class Build( 'part': self.part, 'quantity': self.quantity, 'reference': self.reference, - 'title': str(self) + 'title': str(self), } - @staticmethod def filterByDate(queryset, min_date, max_date): """Filter by 'minimum and maximum date range'. @@ -215,10 +206,19 @@ class Build( return queryset # Order was completed within the specified range - completed = Q(status=BuildStatus.COMPLETE.value) & Q(completion_date__gte=min_date) & Q(completion_date__lte=max_date) + completed = ( + Q(status=BuildStatus.COMPLETE.value) + & Q(completion_date__gte=min_date) + & Q(completion_date__lte=max_date) + ) # Order target date falls within specified range - pending = Q(status__in=BuildStatusGroups.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__gte=min_date) & Q(target_date__lte=max_date) + pending = ( + Q(status__in=BuildStatusGroups.ACTIVE_CODES) + & ~Q(target_date=None) + & Q(target_date__gte=min_date) + & Q(target_date__lte=max_date) + ) # TODO - Construct a queryset for "overdue" orders @@ -227,11 +227,11 @@ class Build( return queryset def __str__(self): - """String representation of a BuildOrder""" + """String representation of a BuildOrder.""" return self.reference def get_absolute_url(self): - """Return the web URL associated with this BuildOrder""" + """Return the web URL associated with this BuildOrder.""" return InvenTree.helpers.pui_url(f'/manufacturing/build-order/{self.id}') reference = models.CharField( @@ -241,22 +241,21 @@ class Build( help_text=_('Build Order Reference'), verbose_name=_('Reference'), default=generate_next_build_reference, - validators=[ - validate_build_order_reference, - ] + validators=[validate_build_order_reference], ) title = models.CharField( verbose_name=_('Description'), blank=True, max_length=100, - help_text=_('Brief description of the build (optional)') + help_text=_('Brief description of the build (optional)'), ) parent = TreeForeignKey( 'self', on_delete=models.SET_NULL, - blank=True, null=True, + blank=True, + null=True, related_name='children', verbose_name=_('Parent Build'), help_text=_('BuildOrder to which this build is allocated'), @@ -267,9 +266,7 @@ class Build( verbose_name=_('Part'), on_delete=models.CASCADE, related_name='builds', - limit_choices_to={ - 'assembly': True, - }, + limit_choices_to={'assembly': True}, help_text=_('Select part to build'), ) @@ -278,8 +275,9 @@ class Build( verbose_name=_('Sales Order Reference'), on_delete=models.SET_NULL, related_name='builds', - null=True, blank=True, - help_text=_('SalesOrder to which this build is allocated') + null=True, + blank=True, + help_text=_('SalesOrder to which this build is allocated'), ) take_from = models.ForeignKey( @@ -287,8 +285,11 @@ class Build( verbose_name=_('Source Location'), on_delete=models.SET_NULL, related_name='sourcing_builds', - null=True, blank=True, - help_text=_('Select location to take stock from for this build (leave blank to take from any stock location)') + null=True, + blank=True, + help_text=_( + 'Select location to take stock from for this build (leave blank to take from any stock location)' + ), ) destination = models.ForeignKey( @@ -296,7 +297,8 @@ class Build( verbose_name=_('Destination Location'), on_delete=models.SET_NULL, related_name='incoming_builds', - null=True, blank=True, + null=True, + blank=True, help_text=_('Select location where the completed items will be stored'), ) @@ -304,13 +306,13 @@ class Build( verbose_name=_('Build Quantity'), default=1, validators=[MinValueValidator(1)], - help_text=_('Number of stock items to build') + help_text=_('Number of stock items to build'), ) completed = models.PositiveIntegerField( verbose_name=_('Completed items'), default=0, - help_text=_('Number of stock items which have been completed') + help_text=_('Number of stock items which have been completed'), ) status = generic.states.fields.InvenTreeCustomStatusModelField( @@ -318,12 +320,12 @@ class Build( default=BuildStatus.PENDING.value, choices=BuildStatus.items(), validators=[MinValueValidator(0)], - help_text=_('Build status code') + help_text=_('Build status code'), ) @property def status_text(self): - """Return the text representation of the status field""" + """Return the text representation of the status field.""" return BuildStatus.text(self.status) batch = models.CharField( @@ -331,31 +333,40 @@ class Build( max_length=100, blank=True, null=True, - help_text=_('Batch code for this build output') + help_text=_('Batch code for this build output'), ) - creation_date = models.DateField(auto_now_add=True, editable=False, verbose_name=_('Creation Date')) + creation_date = models.DateField( + auto_now_add=True, editable=False, verbose_name=_('Creation Date') + ) target_date = models.DateField( - null=True, blank=True, + null=True, + blank=True, verbose_name=_('Target completion date'), - help_text=_('Target date for build completion. Build will be overdue after this date.') + help_text=_( + 'Target date for build completion. Build will be overdue after this date.' + ), ) - completion_date = models.DateField(null=True, blank=True, verbose_name=_('Completion Date')) + completion_date = models.DateField( + null=True, blank=True, verbose_name=_('Completion Date') + ) completed_by = models.ForeignKey( User, on_delete=models.SET_NULL, - blank=True, null=True, + blank=True, + null=True, verbose_name=_('completed by'), - related_name='builds_completed' + related_name='builds_completed', ) issued_by = models.ForeignKey( User, on_delete=models.SET_NULL, - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Issued by'), help_text=_('User who issued this build order'), related_name='builds_issued', @@ -364,28 +375,29 @@ class Build( 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 build order'), related_name='builds_responsible', ) link = InvenTree.fields.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') ) priority = models.PositiveIntegerField( verbose_name=_('Build Priority'), default=0, validators=[MinValueValidator(0)], - help_text=_('Priority of this build order') + help_text=_('Priority of this build order'), ) project_code = models.ForeignKey( - common.models.ProjectCode, + ProjectCode, on_delete=models.SET_NULL, - blank=True, null=True, + blank=True, + null=True, verbose_name=_('Project Code'), help_text=_('Project code for this build order'), ) @@ -408,7 +420,9 @@ class Build( @property def has_open_child_builds(self): """Return True if this build order has any open child builds.""" - return self.sub_builds().filter(status__in=BuildStatusGroups.ACTIVE_CODES).exists() + return ( + self.sub_builds().filter(status__in=BuildStatusGroups.ACTIVE_CODES).exists() + ) @property def is_overdue(self): @@ -459,11 +473,11 @@ class Build( @property def output_count(self): - """Return the number of build outputs (StockItem) associated with this build order""" + """Return the number of build outputs (StockItem) associated with this build order.""" return self.build_outputs.count() def has_build_outputs(self): - """Returns True if this build has more than zero build outputs""" + """Returns True if this build has more than zero build outputs.""" return self.output_count > 0 def get_build_outputs(self, **kwargs): @@ -476,7 +490,7 @@ class Build( outputs = self.build_outputs.all() # Filter by 'in stock' status - in_stock = kwargs.get('in_stock', None) + in_stock = kwargs.get('in_stock') if in_stock is not None: if in_stock: @@ -485,7 +499,7 @@ class Build( outputs = outputs.exclude(stock.models.StockItem.IN_STOCK_FILTER) # Filter by 'complete' status - complete = kwargs.get('complete', None) + complete = kwargs.get('complete') if complete is not None: if complete: @@ -504,7 +518,7 @@ class Build( @property def complete_count(self): - """Return the total quantity of completed outputs""" + """Return the total quantity of completed outputs.""" quantity = 0 for output in self.complete_outputs: @@ -513,7 +527,7 @@ class Build( return quantity def is_partially_allocated(self): - """Test is this build order has any stock allocated against it""" + """Test is this build order has any stock allocated against it.""" return self.allocated_stock.count() > 0 @property @@ -572,14 +586,16 @@ class Build( @property def can_complete(self): - """Returns True if this BuildOrder is ready to be completed + """Returns True if this BuildOrder is ready to be completed. - Must not have any outstanding build outputs - Completed count must meet the required quantity - Untracked parts must be allocated """ - - if get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS') and self.has_open_child_builds: + if ( + get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS') + and self.has_open_child_builds + ): return False if self.status != BuildStatus.PRODUCTION.value: @@ -591,10 +607,7 @@ class Build( if self.remaining > 0: return False - if not self.is_fully_allocated(tracked=False): - return False - - return True + return self.is_fully_allocated(tracked=False) @transaction.atomic def complete_allocations(self, user): @@ -612,21 +625,27 @@ class Build( @transaction.atomic def complete_build(self, user, trim_allocated_stock=False): """Mark this build as complete.""" - return self.handle_transition( - self.status, BuildStatus.COMPLETE.value, self, self._action_complete, user=user, trim_allocated_stock=trim_allocated_stock + self.status, + BuildStatus.COMPLETE.value, + self, + self._action_complete, + user=user, + trim_allocated_stock=trim_allocated_stock, ) def _action_complete(self, *args, **kwargs): """Action to be taken when a build is completed.""" - import build.tasks trim_allocated_stock = kwargs.pop('trim_allocated_stock', False) user = kwargs.pop('user', None) # Prevent completion if there are open child builds - if get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS') and self.has_open_child_builds: + if ( + get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS') + and self.has_open_child_builds + ): return if self.incomplete_count > 0: @@ -645,18 +664,17 @@ class Build( build.tasks.complete_build_allocations, self.pk, user.pk if user else None, - group='build' + group='build', ): - raise ValidationError(_("Failed to offload task to complete build allocations")) + raise ValidationError( + _('Failed to offload task to complete build allocations') + ) # Register an event trigger_event(BuildEvents.COMPLETED, id=self.pk) # Notify users that this build has been completed - targets = [ - self.issued_by, - self.responsible, - ] + targets = [self.issued_by, self.responsible] # Notify those users interested in the parent build if self.parent: @@ -676,11 +694,10 @@ class Build( 'name': name, 'slug': 'build.completed', 'message': _('A build order has been completed'), - 'link': InvenTree.helpers_model.construct_absolute_url(self.get_absolute_url()), - 'template': { - 'html': 'email/build_order_completed.html', - 'subject': name, - } + 'link': InvenTree.helpers_model.construct_absolute_url( + self.get_absolute_url() + ), + 'template': {'html': 'email/build_order_completed.html', 'subject': name}, } trigger_notification( @@ -705,14 +722,10 @@ class Build( @property def can_issue(self): """Returns True if this BuildOrder can be issued.""" - return self.status in [ - BuildStatus.PENDING.value, - BuildStatus.ON_HOLD.value, - ] + return self.status in [BuildStatus.PENDING.value, BuildStatus.ON_HOLD.value] def _action_issue(self, *args, **kwargs): """Perform the action to mark this order as PRODUCTION.""" - if self.can_issue: self.status = BuildStatus.PRODUCTION.value self.save() @@ -722,22 +735,17 @@ class Build( @transaction.atomic def hold_build(self): """Mark the Build as ON HOLD.""" - return self.handle_transition( self.status, BuildStatus.ON_HOLD.value, self, self._action_hold ) @property def can_hold(self): - """Returns True if this BuildOrder can be placed on hold""" - return self.status in [ - BuildStatus.PENDING.value, - BuildStatus.PRODUCTION.value, - ] + """Returns True if this BuildOrder can be placed on hold.""" + return self.status in [BuildStatus.PENDING.value, BuildStatus.PRODUCTION.value] def _action_hold(self, *args, **kwargs): """Action to be taken when a build is placed on hold.""" - if self.can_hold: self.status = BuildStatus.ON_HOLD.value self.save() @@ -752,14 +760,17 @@ class Build( - Set build status to CANCELLED - Save the Build object """ - return self.handle_transition( - self.status, BuildStatus.CANCELLED.value, self, self._action_cancel, user=user, **kwargs + self.status, + BuildStatus.CANCELLED.value, + self, + self._action_cancel, + user=user, + **kwargs, ) def _action_cancel(self, *args, **kwargs): """Action to be taken when a build is cancelled.""" - import build.tasks user = kwargs.pop('user', None) @@ -775,7 +786,9 @@ class Build( user.pk if user else None, group='build', ): - raise ValidationError(_("Failed to offload task to complete build allocations")) + raise ValidationError( + _('Failed to offload task to complete build allocations') + ) else: self.allocated_stock.all().delete() @@ -798,7 +811,7 @@ class Build( self, Build, exclude=self.issued_by, - content=InvenTreeNotificationBodies.OrderCanceled + content=InvenTreeNotificationBodies.OrderCanceled, ) trigger_event(BuildEvents.CANCELLED, id=self.pk) @@ -811,9 +824,7 @@ class Build( build_line: Specify a particular BuildLine instance to un-allocate stock against output: Specify a particular StockItem (output) to un-allocate stock against """ - allocations = self.allocated_stock.filter( - install_into=output - ) + allocations = self.allocated_stock.filter(install_into=output) if build_line: allocations = allocations.filter(build_line=build_line) @@ -833,7 +844,6 @@ class Build( location: Override location auto_allocate: Automatically allocate stock with matching serial numbers """ - trackable_parts = self.part.get_trackable_parts() # Create (and cache) a map of valid parts for allocation @@ -841,12 +851,12 @@ class Build( for bom_item in trackable_parts: parts = bom_item.get_valid_parts_for_allocation() - valid_parts[bom_item.pk] = list([part.pk for part in parts]) + valid_parts[bom_item.pk] = [part.pk for part in parts] - user = kwargs.get('user', None) + user = kwargs.get('user') batch = kwargs.get('batch', self.batch) - location = kwargs.get('location', None) - serials = kwargs.get('serials', None) + location = kwargs.get('location') + serials = kwargs.get('serials') auto_allocate = kwargs.get('auto_allocate', False) if location is None: @@ -854,7 +864,7 @@ class Build( if self.part.has_trackable_parts and not serials: raise ValidationError({ - 'serials': _("Serial numbers must be provided for trackable parts") + 'serials': _('Serial numbers must be provided for trackable parts') }) # We are generating multiple serialized outputs @@ -871,7 +881,7 @@ class Build( build=self, batch=batch, location=location, - is_building=True + is_building=True, ) for output in outputs: @@ -884,15 +894,14 @@ class Build( 'buildorder': self.pk, 'batch': output.batch, 'serial': output.serial, - 'location': location.pk if location else None + 'location': location.pk if location else None, }, - commit=False + commit=False, ): tracking.append(entry) # Auto-allocate stock based on serial number if auto_allocate: - for bom_item in trackable_parts: valid_part_ids = valid_parts.get(bom_item.pk, []) @@ -908,8 +917,7 @@ class Build( # Find the 'BuildLine' object which points to this BomItem try: build_line = BuildLine.objects.get( - build=self, - bom_item=bom_item + build=self, bom_item=bom_item ) # Allocate the stock items against the BuildLine @@ -939,7 +947,7 @@ class Build( part=self.part, build=self, batch=batch, - is_building=True + is_building=True, ) output.add_tracking_entry( @@ -949,8 +957,8 @@ class Build( 'quantity': float(quantity), 'buildorder': self.pk, 'batch': batch, - 'location': location.pk if location else None - } + 'location': location.pk if location else None, + }, ) if self.status == BuildStatus.PENDING: @@ -966,13 +974,13 @@ class Build( - Delete the output StockItem """ if not output: - raise ValidationError(_("No build output specified")) + raise ValidationError(_('No build output specified')) if not output.is_building: - raise ValidationError(_("Build output is already completed")) + raise ValidationError(_('Build output is already completed')) if output.build != self: - raise ValidationError(_("Build output does not match Build Order")) + raise ValidationError(_('Build output does not match Build Order')) # Deallocate all build items against the output self.deallocate_stock(output=output) @@ -993,7 +1001,6 @@ class Build( lines = annotate_allocated_quantity(lines) for build_line in lines: - reduce_by = build_line.allocated - build_line.quantity if reduce_by <= 0: @@ -1001,7 +1008,6 @@ class Build( # Find BuildItem objects to trim for item in BuildItem.objects.filter(build_line=build_line): - # Previous item completed the job if reduce_by <= 0: break @@ -1025,10 +1031,8 @@ class Build( @property def allocated_stock(self): - """Returns a QuerySet object of all BuildItem objects which point back to this Build""" - return BuildItem.objects.filter( - build_line__build=self - ) + """Returns a QuerySet object of all BuildItem objects which point back to this Build.""" + return BuildItem.objects.filter(build_line__build=self) @transaction.atomic def subtract_allocated_stock(self, user): @@ -1047,7 +1051,7 @@ class Build( @transaction.atomic def scrap_build_output(self, output, quantity, location, **kwargs): - """Mark a particular build output as scrapped / rejected + """Mark a particular build output as scrapped / rejected. - Mark the output as "complete" - *Do Not* update the "completed" count for this order @@ -1055,19 +1059,17 @@ class Build( - Add a transaction entry to the stock item history """ if not output: - raise ValidationError(_("No build output specified")) + raise ValidationError(_('No build output specified')) if quantity <= 0: - raise ValidationError({ - 'quantity': _("Quantity must be greater than zero") - }) + raise ValidationError({'quantity': _('Quantity must be greater than zero')}) if quantity > output.quantity: raise ValidationError({ - 'quantity': _("Quantity cannot be greater than the output quantity") + 'quantity': _('Quantity cannot be greater than the output quantity') }) - user = kwargs.get('user', None) + user = kwargs.get('user') notes = kwargs.get('notes', '') discard_allocations = kwargs.get('discard_allocations', False) @@ -1100,7 +1102,7 @@ class Build( 'location': location.pk, 'status': StockStatus.REJECTED.value, 'buildorder': self.pk, - } + }, ) @transaction.atomic @@ -1119,12 +1121,18 @@ class Build( allocated_items = output.items_to_install.all() required_tests = kwargs.get('required_tests', output.part.getRequiredTests()) - prevent_on_incomplete = kwargs.get('prevent_on_incomplete', common.settings.prevent_build_output_complete_on_incompleted_tests()) + prevent_on_incomplete = kwargs.get( + 'prevent_on_incomplete', + prevent_build_output_complete_on_incompleted_tests(), + ) - if (prevent_on_incomplete and not output.passedAllRequiredTests(required_tests=required_tests)): + if prevent_on_incomplete and not output.passedAllRequiredTests( + required_tests=required_tests + ): serial = output.serial raise ValidationError( - _(f"Build output {serial} has not passed all required tests")) + _(f'Build output {serial} has not passed all required tests') + ) for build_item in allocated_items: # Complete the allocation of stock for that item @@ -1141,26 +1149,16 @@ class Build( output.save(add_note=False) - deltas = { - 'status': status, - 'buildorder': self.pk - } + deltas = {'status': status, 'buildorder': self.pk} if location: deltas['location'] = location.pk output.add_tracking_entry( - StockHistoryCode.BUILD_OUTPUT_COMPLETED, - user, - notes=notes, - deltas=deltas + StockHistoryCode.BUILD_OUTPUT_COMPLETED, user, notes=notes, deltas=deltas ) - trigger_event( - BuildEvents.OUTPUT_COMPLETED, - id=output.pk, - build_id=self.pk, - ) + trigger_event(BuildEvents.OUTPUT_COMPLETED, id=output.pk, build_id=self.pk) # Increase the completed quantity for this build self.completed += output.quantity @@ -1181,8 +1179,8 @@ class Build( - If multiple stock items are found, we *may* be able to allocate: - If the calling function has specified that items are interchangeable """ - location = kwargs.get('location', None) - exclude_location = kwargs.get('exclude_location', None) + location = kwargs.get('location') + exclude_location = kwargs.get('exclude_location') interchangeable = kwargs.get('interchangeable', False) substitutes = kwargs.get('substitutes', True) optional_items = kwargs.get('optional_items', False) @@ -1198,7 +1196,6 @@ class Build( # Auto-allocation is only possible for "untracked" line items for line_item in self.untracked_line_items.all(): - # Find the referenced BomItem bom_item = line_item.bom_item @@ -1220,30 +1217,35 @@ class Build( # Check which parts we can "use" (may include variants and substitutes) available_parts = bom_item.get_valid_parts_for_allocation( - allow_variants=True, - allow_substitutes=substitutes, + allow_variants=True, allow_substitutes=substitutes ) # Look for available stock items - available_stock = stock.models.StockItem.objects.filter(stock.models.StockItem.IN_STOCK_FILTER) - - # Filter by list of available parts - available_stock = available_stock.filter( - part__in=list(available_parts), + available_stock = stock.models.StockItem.objects.filter( + stock.models.StockItem.IN_STOCK_FILTER ) + # Filter by list of available parts + available_stock = available_stock.filter(part__in=list(available_parts)) + # Filter out "serialized" stock items, these cannot be auto-allocated - available_stock = available_stock.filter(Q(serial=None) | Q(serial='')).distinct() + available_stock = available_stock.filter( + Q(serial=None) | Q(serial='') + ).distinct() if location: # Filter only stock items located "below" the specified location sublocations = location.get_descendants(include_self=True) - available_stock = available_stock.filter(location__in=list(sublocations)) + available_stock = available_stock.filter( + location__in=list(sublocations) + ) if exclude_location: # Exclude any stock items from the provided location sublocations = exclude_location.get_descendants(include_self=True) - available_stock = available_stock.exclude(location__in=list(sublocations)) + available_stock = available_stock.exclude( + location__in=list(sublocations) + ) """ Next, we sort the available stock items with the following priority: @@ -1253,7 +1255,10 @@ class Build( This ensures that allocation priority is first given to "direct" parts """ - available_stock = sorted(available_stock, key=lambda item, b=bom_item, v=variant_parts: stock_sort(item, b, v)) + available_stock = sorted( + available_stock, + key=lambda item, b=bom_item, v=variant_parts: stock_sort(item, b, v), + ) if len(available_stock) == 0: # No stock items are available @@ -1263,29 +1268,33 @@ class Build( # or all items are "interchangeable" and we don't care where we take stock from for stock_item in available_stock: - # Skip inactive parts if not stock_item.part.active: continue # How much of the stock item is "available" for allocation? - quantity = min(unallocated_quantity, stock_item.unallocated_quantity()) + quantity = min( + unallocated_quantity, stock_item.unallocated_quantity() + ) if quantity > 0: - try: - new_items.append(BuildItem( - build_line=line_item, - stock_item=stock_item, - quantity=quantity, - )) + new_items.append( + BuildItem( + build_line=line_item, + stock_item=stock_item, + quantity=quantity, + ) + ) # Subtract the required quantity unallocated_quantity -= quantity except (ValidationError, serializers.ValidationError) as exc: # Catch model errors and re-throw as DRF errors - raise ValidationError(detail=serializers.as_serializer_error(exc)) + raise ValidationError( + detail=serializers.as_serializer_error(exc) + ) if unallocated_quantity <= 0: # We have now fully-allocated this BomItem - no need to continue! @@ -1324,11 +1333,10 @@ class Build( Returns: True if the BuildOrder has been fully allocated, otherwise False """ - return self.unallocated_lines(tracked=tracked).count() == 0 def is_output_fully_allocated(self, output): - """Determine if the specified output (StockItem) has been fully allocated for this build + """Determine if the specified output (StockItem) has been fully allocated for this build. Args: output: StockItem object (the "in production" output to test against) @@ -1336,17 +1344,13 @@ class Build( To determine if the output has been fully allocated, we need to test all "trackable" BuildLine objects """ - lines = self.build_lines.filter(bom_item__sub_part__trackable=True) lines = lines.exclude(bom_item__consumable=True) # Find any lines which have not been fully allocated for line in lines: # Grab all BuildItem objects which point to this output - allocations = BuildItem.objects.filter( - build_line=line, - install_into=output, - ) + allocations = BuildItem.objects.filter(build_line=line, install_into=output) allocated = allocations.aggregate( q=Coalesce(Sum('quantity'), 0, output_field=models.DecimalField()) @@ -1365,7 +1369,6 @@ class Build( Returns: True if any BuildLine has been over-allocated. """ - lines = self.build_lines.all().exclude(bom_item__consumable=True) lines = annotate_allocated_quantity(lines) @@ -1396,34 +1399,36 @@ class Build( bom_items = self.part.get_bom_items() - logger.info("Creating BuildLine objects for BuildOrder %s (%s items)", self.pk, len(bom_items)) + logger.info( + 'Creating BuildLine objects for BuildOrder %s (%s items)', + self.pk, + len(bom_items), + ) # Iterate through each part required to build the parent part for bom_item in bom_items: if prevent_duplicates: if BuildLine.objects.filter(build=self, bom_item=bom_item).exists(): - logger.info("BuildLine already exists for BuildOrder %s and BomItem %s", self.pk, bom_item.pk) + logger.info( + 'BuildLine already exists for BuildOrder %s and BomItem %s', + self.pk, + bom_item.pk, + ) continue # Calculate required quantity quantity = bom_item.get_required_quantity(self.quantity) - lines.append( - BuildLine( - build=self, - bom_item=bom_item, - quantity=quantity - ) - ) + lines.append(BuildLine(build=self, bom_item=bom_item, quantity=quantity)) BuildLine.objects.bulk_create(lines) if len(lines) > 0: - logger.info("Created %s BuildLine objects for BuildOrder", len(lines)) + logger.info('Created %s BuildLine objects for BuildOrder', len(lines)) @transaction.atomic def update_build_line_items(self): - """Rebuild required quantity field for each BuildLine object""" + """Rebuild required quantity field for each BuildLine object.""" lines_to_update = [] for line in self.build_lines.all(): @@ -1432,20 +1437,21 @@ class Build( BuildLine.objects.bulk_update(lines_to_update, ['quantity']) - logger.info("Updated %s BuildLine objects for BuildOrder", len(lines_to_update)) + logger.info('Updated %s BuildLine objects for BuildOrder', len(lines_to_update)) @receiver(post_save, sender=Build, dispatch_uid='build_post_save_log') def after_save_build(sender, instance: Build, created: bool, **kwargs): """Callback function to be executed after a Build instance is saved.""" # Escape if we are importing data - if InvenTree.ready.isImportingData() or not InvenTree.ready.canAppAccessDatabase(allow_test=True): + if InvenTree.ready.isImportingData() or not InvenTree.ready.canAppAccessDatabase( + allow_test=True + ): return from . import tasks as build_tasks if instance: - if created: # A new Build has just been created @@ -1454,13 +1460,13 @@ def after_save_build(sender, instance: Build, created: bool, **kwargs): # Run checks on required parts InvenTree.tasks.offload_task( - build_tasks.check_build_stock, - instance, - group='build' + build_tasks.check_build_stock, instance, group='build' ) # Notify the responsible users that the build order has been created - InvenTree.helpers_model.notify_responsible(instance, sender, exclude=instance.issued_by) + InvenTree.helpers_model.notify_responsible( + instance, sender, exclude=instance.issued_by + ) else: # Update BuildLine objects if the Build quantity has changed @@ -1485,19 +1491,17 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo class Meta: """Model meta options.""" + verbose_name = _('Build Order Line Item') - unique_together = [ - ('build', 'bom_item'), - ] + unique_together = [('build', 'bom_item')] @staticmethod def get_api_url(): - """Return the API URL used to access this model""" + """Return the API URL used to access this model.""" return reverse('api-build-line-list') def report_context(self): """Generate custom report context for this BuildLine object.""" - return { 'allocated_quantity': self.allocated_quantity, 'allocations': self.allocations, @@ -1509,14 +1513,14 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo } build = models.ForeignKey( - Build, on_delete=models.CASCADE, - related_name='build_lines', help_text=_('Build object') + Build, + on_delete=models.CASCADE, + related_name='build_lines', + help_text=_('Build object'), ) bom_item = models.ForeignKey( - part.models.BomItem, - on_delete=models.CASCADE, - related_name='build_lines', + part.models.BomItem, on_delete=models.CASCADE, related_name='build_lines' ) quantity = models.DecimalField( @@ -1530,11 +1534,11 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo @property def part(self): - """Return the sub_part reference from the link bom_item""" + """Return the sub_part reference from the link bom_item.""" return self.bom_item.sub_part def allocated_quantity(self): - """Calculate the total allocated quantity for this BuildLine""" + """Calculate the total allocated quantity for this BuildLine.""" # Queryset containing all BuildItem objects allocated against this BuildLine allocations = self.allocations.all() @@ -1545,18 +1549,18 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo return allocated['q'] def unallocated_quantity(self): - """Return the unallocated quantity for this BuildLine""" + """Return the unallocated quantity for this BuildLine.""" return max(self.quantity - self.allocated_quantity(), 0) def is_fully_allocated(self): - """Return True if this BuildLine is fully allocated""" + """Return True if this BuildLine is fully allocated.""" if self.bom_item.consumable: return True return self.allocated_quantity() >= self.quantity def is_overallocated(self): - """Return True if this BuildLine is over-allocated""" + """Return True if this BuildLine is over-allocated.""" return self.allocated_quantity() > self.quantity @@ -1575,17 +1579,16 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel): class Meta: """Model meta options.""" - unique_together = [ - ('build_line', 'stock_item', 'install_into'), - ] + + unique_together = [('build_line', 'stock_item', 'install_into')] @staticmethod def get_api_url(): - """Return the API URL used to access this model""" + """Return the API URL used to access this model.""" return reverse('api-build-item-list') def save(self, *args, **kwargs): - """Custom save method for the BuildItem model""" + """Custom save method for the BuildItem model.""" self.clean() super().save() @@ -1602,42 +1605,52 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel): super().clean() try: - # If the 'part' is trackable, then the 'install_into' field must be set! - if self.stock_item.part and self.stock_item.part.trackable and not self.install_into: - raise ValidationError(_('Build item must specify a build output, as master part is marked as trackable')) + if ( + self.stock_item.part + and self.stock_item.part.trackable + and not self.install_into + ): + raise ValidationError( + _( + 'Build item must specify a build output, as master part is marked as trackable' + ) + ) # Allocated quantity cannot exceed available stock quantity if self.quantity > self.stock_item.quantity: - q = InvenTree.helpers.normalize(self.quantity) a = InvenTree.helpers.normalize(self.stock_item.quantity) raise ValidationError({ - 'quantity': _(f'Allocated quantity ({q}) must not exceed available stock quantity ({a})') + 'quantity': _( + f'Allocated quantity ({q}) must not exceed available stock quantity ({a})' + ) }) # Ensure that we do not 'over allocate' a stock item available = decimal.Decimal(self.stock_item.quantity) quantity = decimal.Decimal(self.quantity) - build_allocation_count = decimal.Decimal(self.stock_item.build_allocation_count( - exclude_allocations={'pk': self.pk} - )) - sales_allocation_count = decimal.Decimal(self.stock_item.sales_order_allocation_count()) + build_allocation_count = decimal.Decimal( + self.stock_item.build_allocation_count( + exclude_allocations={'pk': self.pk} + ) + ) + sales_allocation_count = decimal.Decimal( + self.stock_item.sales_order_allocation_count() + ) total_allocation = ( build_allocation_count + sales_allocation_count + quantity ) if total_allocation > available: - raise ValidationError({ - 'quantity': _('Stock item is over-allocated') - }) + raise ValidationError({'quantity': _('Stock item is over-allocated')}) # Allocated quantity must be positive if self.quantity <= 0: raise ValidationError({ - 'quantity': _('Allocation quantity must be greater than zero'), + 'quantity': _('Allocation quantity must be greater than zero') }) # Quantity must be 1 for serialized stock @@ -1647,9 +1660,9 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel): }) except stock.models.StockItem.DoesNotExist: - raise ValidationError("Stock item must be specified") + raise ValidationError('Stock item must be specified') except part.models.Part.DoesNotExist: - raise ValidationError("Part must be specified") + raise ValidationError('Part must be specified') """ Attempt to find the "BomItem" which links this BuildItem to the build. @@ -1675,18 +1688,20 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel): valid = self.bom_item.is_stock_item_valid(self.stock_item) elif self.bom_item.inherited: - if self.build.part in self.bom_item.part.get_descendants(include_self=False): + if self.build.part in self.bom_item.part.get_descendants( + include_self=False + ): valid = self.bom_item.is_stock_item_valid(self.stock_item) # If the existing BomItem is *not* valid, try to find a match if not valid and self.build and self.stock_item: - ancestors = self.stock_item.part.get_ancestors(include_self=True, ascending=True) + ancestors = self.stock_item.part.get_ancestors( + include_self=True, ascending=True + ) for idx, ancestor in enumerate(ancestors): - build_line = BuildLine.objects.filter( - build=self.build, - bom_item__part=ancestor, + build=self.build, bom_item__part=ancestor ) if build_line.exists(): @@ -1700,19 +1715,18 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel): # BomItem did not exist or could not be validated. # Search for a new one if not valid: - raise ValidationError({ - 'stock_item': _("Selected stock item does not match BOM line") + 'stock_item': _('Selected stock item does not match BOM line') }) @property def build(self): - """Return the BuildOrder associated with this BuildItem""" + """Return the BuildOrder associated with this BuildItem.""" return self.build_line.build if self.build_line else None @property def bom_item(self): - """Return the BomItem associated with this BuildItem""" + """Return the BomItem associated with this BuildItem.""" return self.build_line.bom_item if self.build_line else None @transaction.atomic @@ -1729,27 +1743,17 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel): # Split the allocated stock if there are more available than allocated if item.quantity > self.quantity: - item = item.splitStock( - self.quantity, - None, - user, - notes=notes, - ) + item = item.splitStock(self.quantity, None, user, notes=notes) # For a trackable part, special consideration needed! if item.part.trackable: - # Make sure we are pointing to the new item self.stock_item = item self.save() # Install the stock item into the output self.install_into.installStockItem( - item, - self.quantity, - user, - notes, - build=self.build, + item, self.quantity, user, notes, build=self.build ) else: @@ -1762,16 +1766,11 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel): StockHistoryCode.BUILD_CONSUMED, user, notes=notes, - deltas={ - 'buildorder': self.build.pk, - 'quantity': float(item.quantity), - } + deltas={'buildorder': self.build.pk, 'quantity': float(item.quantity)}, ) build_line = models.ForeignKey( - BuildLine, - on_delete=models.CASCADE, null=True, - related_name='allocations', + BuildLine, on_delete=models.CASCADE, null=True, related_name='allocations' ) stock_item = models.ForeignKey( @@ -1780,10 +1779,7 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel): related_name='allocations', verbose_name=_('Stock Item'), help_text=_('Source stock item'), - limit_choices_to={ - 'sales_order': None, - 'belongs_to': None, - } + limit_choices_to={'sales_order': None, 'belongs_to': None}, ) quantity = models.DecimalField( @@ -1792,17 +1788,16 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel): default=1, validators=[MinValueValidator(0)], verbose_name=_('Quantity'), - help_text=_('Stock quantity to allocate to build') + help_text=_('Stock quantity to allocate to build'), ) install_into = models.ForeignKey( 'stock.StockItem', on_delete=models.SET_NULL, - blank=True, null=True, + blank=True, + null=True, related_name='items_to_install', verbose_name=_('Install into'), help_text=_('Destination stock item'), - limit_choices_to={ - 'is_building': True, - } + limit_choices_to={'is_building': True}, ) diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 845cd584bd..1e10c0db96 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -7,7 +7,6 @@ from django.db import models, transaction from django.db.models import ( BooleanField, Case, - Count, ExpressionWrapper, F, FloatField, @@ -49,11 +48,17 @@ from .models import Build, BuildItem, BuildLine from .status_codes import BuildStatus -class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTreeCustomStatusSerializerMixin, InvenTreeModelSerializer): +class BuildSerializer( + NotesFieldMixin, + DataImportExportSerializerMixin, + InvenTreeCustomStatusSerializerMixin, + InvenTreeModelSerializer, +): """Serializes a Build object.""" class Meta: - """Serializer metaclass""" + """Serializer metaclass.""" + model = Build fields = [ 'pk', @@ -89,7 +94,6 @@ class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTre 'responsible_detail', 'priority', 'level', - # Additional fields used only for build order creation 'create_child_builds', ] @@ -111,9 +115,13 @@ class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTre status_text = serializers.CharField(source='get_status_display', read_only=True) - part_detail = part_serializers.PartBriefSerializer(source='part', many=False, read_only=True) + part_detail = part_serializers.PartBriefSerializer( + source='part', many=False, read_only=True + ) - part_name = serializers.CharField(source='part.name', read_only=True, label=_('Part Name')) + part_name = serializers.CharField( + source='part.name', read_only=True, label=_('Part Name') + ) quantity = InvenTreeDecimalField() @@ -125,12 +133,18 @@ class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTre barcode_hash = serializers.CharField(read_only=True) - project_code_label = serializers.CharField(source='project_code.code', read_only=True, label=_('Project Code Label')) + project_code_label = serializers.CharField( + source='project_code.code', read_only=True, label=_('Project Code Label') + ) - project_code_detail = ProjectCodeSerializer(source='project_code', many=False, read_only=True) + project_code_detail = ProjectCodeSerializer( + source='project_code', many=False, read_only=True + ) create_child_builds = serializers.BooleanField( - default=False, required=False, write_only=True, + default=False, + required=False, + write_only=True, label=_('Create Child Builds'), help_text=_('Automatically generate child build orders'), ) @@ -148,16 +162,16 @@ class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTre queryset = queryset.annotate( overdue=Case( When( - Build.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()), + Build.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()) ), - default=Value(False, output_field=BooleanField()) + default=Value(False, output_field=BooleanField()), ) ) return queryset def __init__(self, *args, **kwargs): - """Determine if extra serializer fields are required""" + """Determine if extra serializer fields are required.""" part_detail = kwargs.pop('part_detail', True) create = kwargs.pop('create', False) @@ -174,7 +188,7 @@ class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTre return ['create_child_builds'] def validate_reference(self, reference): - """Custom validation for the Build reference field""" + """Custom validation for the Build reference field.""" # Ensure the reference matches the required pattern Build.validate_reference_field(reference) @@ -183,7 +197,6 @@ class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTre @transaction.atomic def create(self, validated_data): """Save the Build object.""" - build_order = super().create(validated_data) create_child_builds = self.validated_data.pop('create_child_builds', False) @@ -191,9 +204,7 @@ class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTre if create_child_builds: # Pass child build creation off to the background thread InvenTree.tasks.offload_task( - build.tasks.create_child_builds, - build_order.pk, - group='build' + build.tasks.create_child_builds, build_order.pk, group='build' ) return build_order @@ -206,10 +217,9 @@ class BuildOutputSerializer(serializers.Serializer): """ class Meta: - """Serializer metaclass""" - fields = [ - 'output', - ] + """Serializer metaclass.""" + + fields = ['output'] output = serializers.PrimaryKeyRelatedField( queryset=StockItem.objects.all(), @@ -220,7 +230,7 @@ class BuildOutputSerializer(serializers.Serializer): ) def validate_output(self, output): - """Perform validation for the output (StockItem) provided to the serializer""" + """Perform validation for the output (StockItem) provided to the serializer.""" build = self.context['build'] # As this serializer can be used in multiple contexts, we need to work out why we are here @@ -228,38 +238,39 @@ class BuildOutputSerializer(serializers.Serializer): # The stock item must point to the build if output.build != build: - raise ValidationError(_("Build output does not match the parent build")) + raise ValidationError(_('Build output does not match the parent build')) # The part must match! if output.part != build.part: - raise ValidationError(_("Output part does not match BuildOrder part")) + raise ValidationError(_('Output part does not match BuildOrder part')) # The build output must be "in production" if not output.is_building: - raise ValidationError(_("This build output has already been completed")) + raise ValidationError(_('This build output has already been completed')) if to_complete: - # The build output must have all tracked parts allocated if not build.is_output_fully_allocated(output): - # Check if the user has specified that incomplete allocations are ok - accept_incomplete = InvenTree.helpers.str2bool(self.context['request'].data.get('accept_incomplete_allocation', False)) + accept_incomplete = InvenTree.helpers.str2bool( + self.context['request'].data.get( + 'accept_incomplete_allocation', False + ) + ) if not accept_incomplete: - raise ValidationError(_("This build output is not fully allocated")) + raise ValidationError(_('This build output is not fully allocated')) return output class BuildOutputQuantitySerializer(BuildOutputSerializer): - """Serializer for a single build output, with additional quantity field""" + """Build output with quantity field.""" class Meta: - """Serializer metaclass""" - fields = BuildOutputSerializer.Meta.fields + [ - 'quantity', - ] + """Serializer metaclass.""" + + fields = [*BuildOutputSerializer.Meta.fields, 'quantity'] quantity = serializers.DecimalField( max_digits=15, @@ -271,20 +282,18 @@ class BuildOutputQuantitySerializer(BuildOutputSerializer): ) def validate(self, data): - """Validate the serializer data""" + """Validate the serializer data.""" data = super().validate(data) output = data.get('output') quantity = data.get('quantity') if quantity <= 0: - raise ValidationError({ - 'quantity': _('Quantity must be greater than zero') - }) + raise ValidationError({'quantity': _('Quantity must be greater than zero')}) if quantity > output.quantity: raise ValidationError({ - 'quantity': _("Quantity cannot be greater than the output quantity") + 'quantity': _('Quantity cannot be greater than the output quantity') }) return data @@ -300,6 +309,7 @@ class BuildOutputCreateSerializer(serializers.Serializer): class Meta: """Serializer metaclass.""" + fields = [ 'quantity', 'batch_code', @@ -318,27 +328,33 @@ class BuildOutputCreateSerializer(serializers.Serializer): ) def get_build(self): - """Return the Build instance associated with this serializer""" - return self.context["build"] + """Return the Build instance associated with this serializer.""" + return self.context['build'] def get_part(self): - """Return the Part instance associated with the build""" + """Return the Part instance associated with the build.""" return self.get_build().part def validate_quantity(self, quantity): - """Validate the provided quantity field""" + """Validate the provided quantity field.""" if quantity <= 0: - raise ValidationError(_("Quantity must be greater than zero")) + raise ValidationError(_('Quantity must be greater than zero')) part = self.get_part() if int(quantity) != quantity: # Quantity must be an integer value if the part being built is trackable if part.trackable: - raise ValidationError(_("Integer quantity required for trackable parts")) + raise ValidationError( + _('Integer quantity required for trackable parts') + ) if part.has_trackable_parts: - raise ValidationError(_("Integer quantity required, as the bill of materials contains trackable parts")) + raise ValidationError( + _( + 'Integer quantity required, as the bill of materials contains trackable parts' + ) + ) return quantity @@ -361,11 +377,12 @@ class BuildOutputCreateSerializer(serializers.Serializer): queryset=StockLocation.objects.all(), label=_('Location'), help_text=_('Stock location for build output'), - required=False, allow_null=True + required=False, + allow_null=True, ) def validate_serial_numbers(self, serial_numbers): - """Clean the provided serial number string""" + """Clean the provided serial number string.""" serial_numbers = serial_numbers.strip() return serial_numbers @@ -375,7 +392,9 @@ class BuildOutputCreateSerializer(serializers.Serializer): default=False, allow_null=True, label=_('Auto Allocate Serial Numbers'), - help_text=_('Automatically allocate required items with matching serial numbers'), + help_text=_( + 'Automatically allocate required items with matching serial numbers' + ), ) def validate(self, data): @@ -390,40 +409,33 @@ class BuildOutputCreateSerializer(serializers.Serializer): if part.trackable and not serial_numbers: raise ValidationError({ - 'serial_numbers': _('Serial numbers must be provided for trackable parts') + 'serial_numbers': _( + 'Serial numbers must be provided for trackable parts' + ) }) if serial_numbers: - try: self.serials = InvenTree.helpers.extract_serial_numbers( - serial_numbers, - quantity, - part.get_latest_serial_number(), - part=part + serial_numbers, quantity, part.get_latest_serial_number(), part=part ) except DjangoValidationError as e: - raise ValidationError({ - 'serial_numbers': e.messages, - }) + raise ValidationError({'serial_numbers': e.messages}) # Check for conflicting serial numbesr existing = part.find_conflicting_serial_numbers(self.serials) if len(existing) > 0: + msg = _('The following serial numbers already exist or are invalid') + msg += ' : ' + msg += ','.join([str(e) for e in existing]) - msg = _("The following serial numbers already exist or are invalid") - msg += " : " - msg += ",".join([str(e) for e in existing]) - - raise ValidationError({ - 'serial_numbers': msg, - }) + raise ValidationError({'serial_numbers': msg}) return data def save(self): - """Generate the new build output(s)""" + """Generate the new build output(s).""" data = self.validated_data build = self.get_build() @@ -442,24 +454,20 @@ class BuildOutputDeleteSerializer(serializers.Serializer): """DRF serializer for deleting (cancelling) one or more build outputs.""" class Meta: - """Serializer metaclass""" - fields = [ - 'outputs', - ] + """Serializer metaclass.""" - outputs = BuildOutputSerializer( - many=True, - required=True, - ) + fields = ['outputs'] + + outputs = BuildOutputSerializer(many=True, required=True) def validate(self, data): - """Perform data validation for this serializer""" + """Perform data validation for this serializer.""" data = super().validate(data) outputs = data.get('outputs', []) if len(outputs) == 0: - raise ValidationError(_("A list of build outputs must be provided")) + raise ValidationError(_('A list of build outputs must be provided')) return data @@ -477,20 +485,14 @@ class BuildOutputDeleteSerializer(serializers.Serializer): class BuildOutputScrapSerializer(serializers.Serializer): - """DRF serializer for scrapping one or more build outputs""" + """Scrapping one or more build outputs.""" class Meta: - """Serializer metaclass""" - fields = [ - 'outputs', - 'location', - 'notes', - ] + """Serializer metaclass.""" - outputs = BuildOutputQuantitySerializer( - many=True, - required=True, - ) + fields = ['outputs', 'location', 'notes'] + + outputs = BuildOutputQuantitySerializer(many=True, required=True) location = serializers.PrimaryKeyRelatedField( queryset=StockLocation.objects.all(), @@ -516,17 +518,17 @@ class BuildOutputScrapSerializer(serializers.Serializer): ) def validate(self, data): - """Perform validation on the serializer data""" + """Perform validation on the serializer data.""" super().validate(data) outputs = data.get('outputs', []) if len(outputs) == 0: - raise ValidationError(_("A list of build outputs must be provided")) + raise ValidationError(_('A list of build outputs must be provided')) return data def save(self): - """Save the serializer to scrap the build outputs""" + """Save the serializer to scrap the build outputs.""" build = self.context['build'] request = self.context['request'] data = self.validated_data @@ -543,7 +545,7 @@ class BuildOutputScrapSerializer(serializers.Serializer): data.get('location', None), user=request.user, notes=data.get('notes', ''), - discard_allocations=data.get('discard_allocations', False) + discard_allocations=data.get('discard_allocations', False), ) @@ -551,7 +553,8 @@ class BuildOutputCompleteSerializer(serializers.Serializer): """DRF serializer for completing one or more build outputs.""" class Meta: - """Serializer metaclass""" + """Serializer metaclass.""" + fields = [ 'outputs', 'location', @@ -560,23 +563,18 @@ class BuildOutputCompleteSerializer(serializers.Serializer): 'notes', ] - outputs = BuildOutputSerializer( - many=True, - required=True, - ) + outputs = BuildOutputSerializer(many=True, required=True) location = serializers.PrimaryKeyRelatedField( queryset=StockLocation.objects.all(), required=True, many=False, - label=_("Location"), - help_text=_("Location for completed build outputs"), + label=_('Location'), + help_text=_('Location for completed build outputs'), ) status_custom_key = serializers.ChoiceField( - choices=StockStatus.items(), - default=StockStatus.OK.value, - label=_("Status"), + choices=StockStatus.items(), default=StockStatus.OK.value, label=_('Status') ) accept_incomplete_allocation = serializers.BooleanField( @@ -586,14 +584,10 @@ class BuildOutputCompleteSerializer(serializers.Serializer): help_text=_('Complete outputs if stock has not been fully allocated'), ) - notes = serializers.CharField( - label=_("Notes"), - required=False, - allow_blank=True, - ) + notes = serializers.CharField(label=_('Notes'), required=False, allow_blank=True) def validate(self, data): - """Perform data validation for this serializer""" + """Perform data validation for this serializer.""" super().validate(data) outputs = data.get('outputs', []) @@ -602,15 +596,20 @@ class BuildOutputCompleteSerializer(serializers.Serializer): errors = [] for output in outputs: stock_item = output['output'] - if stock_item.hasRequiredTests() and not stock_item.passedAllRequiredTests(): + if ( + stock_item.hasRequiredTests() + and not stock_item.passedAllRequiredTests() + ): serial = stock_item.serial - errors.append(_(f"Build output {serial} has not passed all required tests")) + errors.append( + _(f'Build output {serial} has not passed all required tests') + ) if errors: raise ValidationError(errors) if len(outputs) == 0: - raise ValidationError(_("A list of build outputs must be provided")) + raise ValidationError(_('A list of build outputs must be provided')) return data @@ -629,7 +628,9 @@ class BuildOutputCompleteSerializer(serializers.Serializer): # Cache some calculated values which can be passed to each output required_tests = outputs[0]['output'].part.getRequiredTests() - prevent_on_incomplete = common.settings.prevent_build_output_complete_on_incompleted_tests() + prevent_on_incomplete = ( + common.settings.prevent_build_output_complete_on_incompleted_tests() + ) # Mark the specified build outputs as "complete" with transaction.atomic(): @@ -651,11 +652,12 @@ class BuildIssueSerializer(serializers.Serializer): """DRF serializer for issuing a build order.""" class Meta: - """Serializer metaclass""" + """Serializer metaclass.""" + fields = [] def save(self): - """Issue the specified build order""" + """Issue the specified build order.""" build = self.context['build'] build.issue_build() @@ -665,6 +667,7 @@ class BuildHoldSerializer(serializers.Serializer): class Meta: """Serializer metaclass.""" + fields = [] def save(self): @@ -675,17 +678,15 @@ class BuildHoldSerializer(serializers.Serializer): class BuildCancelSerializer(serializers.Serializer): - """DRF serializer class for cancelling an active BuildOrder""" + """Cancel an active BuildOrder.""" class Meta: - """Serializer metaclass""" - fields = [ - 'remove_allocated_stock', - 'remove_incomplete_outputs', - ] + """Serializer metaclass.""" + + fields = ['remove_allocated_stock', 'remove_incomplete_outputs'] def get_context_data(self): - """Retrieve extra context data from this serializer""" + """Retrieve extra context data from this serializer.""" build = self.context['build'] return { @@ -709,7 +710,7 @@ class BuildCancelSerializer(serializers.Serializer): ) def save(self): - """Cancel the specified build""" + """Cancel the specified build.""" build = self.context['build'] request = self.context['request'] @@ -722,7 +723,7 @@ class BuildCancelSerializer(serializers.Serializer): ) -class OverallocationChoice(): +class OverallocationChoice: """Utility class to contain options for handling over allocated stock items.""" REJECT = 'reject' @@ -740,12 +741,9 @@ class BuildCompleteSerializer(serializers.Serializer): """DRF serializer for marking a BuildOrder as complete.""" class Meta: - """Serializer metaclass""" - fields = [ - 'accept_overallocated', - 'accept_unallocated', - 'accept_incomplete', - ] + """Serializer metaclass.""" + + fields = ['accept_overallocated', 'accept_unallocated', 'accept_incomplete'] def get_context_data(self): """Retrieve extra context data for this serializer. @@ -764,13 +762,15 @@ class BuildCompleteSerializer(serializers.Serializer): accept_overallocated = serializers.ChoiceField( label=_('Overallocated Stock'), choices=list(OverallocationChoice.OPTIONS.items()), - help_text=_('How do you want to handle extra stock items assigned to the build order'), + help_text=_( + 'How do you want to handle extra stock items assigned to the build order' + ), required=False, default=OverallocationChoice.REJECT, ) def validate_accept_overallocated(self, value): - """Check if the 'accept_overallocated' field is required""" + """Check if the 'accept_overallocated' field is required.""" build = self.context['build'] if build.is_overallocated() and value == OverallocationChoice.REJECT: @@ -780,13 +780,15 @@ class BuildCompleteSerializer(serializers.Serializer): accept_unallocated = serializers.BooleanField( label=_('Accept Unallocated'), - help_text=_('Accept that stock items have not been fully allocated to this build order'), + help_text=_( + 'Accept that stock items have not been fully allocated to this build order' + ), required=False, default=False, ) def validate_accept_unallocated(self, value): - """Check if the 'accept_unallocated' field is required""" + """Check if the 'accept_unallocated' field is required.""" build = self.context['build'] if not build.are_untracked_parts_allocated and not value: @@ -796,13 +798,15 @@ class BuildCompleteSerializer(serializers.Serializer): accept_incomplete = serializers.BooleanField( label=_('Accept Incomplete'), - help_text=_('Accept that the required number of build outputs have not been completed'), + help_text=_( + 'Accept that the required number of build outputs have not been completed' + ), required=False, default=False, ) def validate_accept_incomplete(self, value): - """Check if the 'accept_incomplete' field is required""" + """Check if the 'accept_incomplete' field is required.""" build = self.context['build'] if build.remaining > 0 and not value: @@ -811,22 +815,25 @@ class BuildCompleteSerializer(serializers.Serializer): return value def validate(self, data): - """Perform validation of this serializer prior to saving""" + """Perform validation of this serializer prior to saving.""" build = self.context['build'] - if get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS') and build.has_open_child_builds: - raise ValidationError(_("Build order has open child build orders")) + if ( + get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS') + and build.has_open_child_builds + ): + raise ValidationError(_('Build order has open child build orders')) if build.status != BuildStatus.PRODUCTION.value: - raise ValidationError(_("Build order must be in production state")) + raise ValidationError(_('Build order must be in production state')) if build.incomplete_count > 0: - raise ValidationError(_("Build order has incomplete outputs")) + raise ValidationError(_('Build order has incomplete outputs')) return data def save(self): - """Complete the specified build output""" + """Complete the specified build output.""" request = self.context['request'] build = self.context['build'] @@ -834,7 +841,10 @@ class BuildCompleteSerializer(serializers.Serializer): build.complete_build( request.user, - trim_allocated_stock=data.get('accept_overallocated', OverallocationChoice.REJECT) == OverallocationChoice.TRIM + trim_allocated_stock=data.get( + 'accept_overallocated', OverallocationChoice.REJECT + ) + == OverallocationChoice.TRIM, ) @@ -848,11 +858,9 @@ class BuildUnallocationSerializer(serializers.Serializer): """ class Meta: - """Serializer metaclass""" - fields = [ - 'build_line', - 'output', - ] + """Serializer metaclass.""" + + fields = ['build_line', 'output'] build_line = serializers.PrimaryKeyRelatedField( queryset=BuildLine.objects.all(), @@ -863,13 +871,11 @@ class BuildUnallocationSerializer(serializers.Serializer): ) output = serializers.PrimaryKeyRelatedField( - queryset=StockItem.objects.filter( - is_building=True, - ), + queryset=StockItem.objects.filter(is_building=True), many=False, allow_null=True, required=False, - label=_("Build output"), + label=_('Build output'), ) def validate_output(self, stock_item): @@ -877,7 +883,7 @@ class BuildUnallocationSerializer(serializers.Serializer): build = self.context['build'] if stock_item and stock_item.build != build: - raise ValidationError(_("Build output must point to the same build")) + raise ValidationError(_('Build output must point to the same build')) return stock_item @@ -891,8 +897,7 @@ class BuildUnallocationSerializer(serializers.Serializer): data = self.validated_data build.deallocate_stock( - build_line=data.get('build_line', None), - output=data.get('output', None), + build_line=data.get('build_line', None), output=data.get('output', None) ) @@ -900,13 +905,9 @@ class BuildAllocationItemSerializer(serializers.Serializer): """A serializer for allocating a single stock item against a build order.""" class Meta: - """Serializer metaclass""" - fields = [ - 'build_item', - 'stock_item', - 'quantity', - 'output', - ] + """Serializer metaclass.""" + + fields = ['build_item', 'stock_item', 'quantity', 'output'] build_line = serializers.PrimaryKeyRelatedField( queryset=BuildLine.objects.all(), @@ -917,17 +918,22 @@ class BuildAllocationItemSerializer(serializers.Serializer): ) def validate_build_line(self, build_line): - """Check if the parts match""" + """Check if the parts match.""" build = self.context['build'] # BomItem should point to the same 'part' as the parent build if build.part != build_line.bom_item.part: - # If not, it may be marked as "inherited" from a parent part - if build_line.bom_item.inherited and build.part in build_line.bom_item.part.get_descendants(include_self=False): + if ( + build_line.bom_item.inherited + and build.part + in build_line.bom_item.part.get_descendants(include_self=False) + ): pass else: - raise ValidationError(_("bom_item.part must point to the same part as the build order")) + raise ValidationError( + _('bom_item.part must point to the same part as the build order') + ) return build_line @@ -940,23 +946,20 @@ class BuildAllocationItemSerializer(serializers.Serializer): ) def validate_stock_item(self, stock_item): - """Perform validation of the stock_item field""" + """Perform validation of the stock_item field.""" if not stock_item.in_stock: - raise ValidationError(_("Item must be in stock")) + raise ValidationError(_('Item must be in stock')) return stock_item quantity = serializers.DecimalField( - max_digits=15, - decimal_places=5, - min_value=Decimal(0), - required=True + max_digits=15, decimal_places=5, min_value=Decimal(0), required=True ) def validate_quantity(self, quantity): - """Perform validation of the 'quantity' field""" + """Perform validation of the 'quantity' field.""" if quantity <= 0: - raise ValidationError(_("Quantity must be greater than zero")) + raise ValidationError(_('Quantity must be greater than zero')) return quantity @@ -969,7 +972,7 @@ class BuildAllocationItemSerializer(serializers.Serializer): ) def validate(self, data): - """Perform data validation for this item""" + """Perform data validation for this item.""" super().validate(data) build_line = data['build_line'] @@ -986,24 +989,24 @@ class BuildAllocationItemSerializer(serializers.Serializer): q = stock_item.unallocated_quantity() if quantity > q: - q = InvenTree.helpers.clean_decimal(q) - raise ValidationError({ - 'quantity': _(f"Available quantity ({q}) exceeded") - }) + raise ValidationError({'quantity': _(f'Available quantity ({q}) exceeded')}) # Output *must* be set for trackable parts if output is None and build_line.bom_item.sub_part.trackable: raise ValidationError({ - 'output': _('Build output must be specified for allocation of tracked parts'), + 'output': _( + 'Build output must be specified for allocation of tracked parts' + ) }) # Output *cannot* be set for un-tracked parts if output is not None and not build_line.bom_item.sub_part.trackable: - raise ValidationError({ - 'output': _('Build output cannot be specified for allocation of untracked parts'), + 'output': _( + 'Build output cannot be specified for allocation of untracked parts' + ) }) return data @@ -1013,10 +1016,9 @@ class BuildAllocationSerializer(serializers.Serializer): """DRF serializer for allocation stock items against a build order.""" class Meta: - """Serializer metaclass""" - fields = [ - 'items', - ] + """Serializer metaclass.""" + + fields = ['items'] items = BuildAllocationItemSerializer(many=True) @@ -1032,7 +1034,7 @@ class BuildAllocationSerializer(serializers.Serializer): return data def save(self): - """Perform the allocation""" + """Perform the allocation.""" data = self.validated_data items = data.get('items', []) @@ -1049,9 +1051,9 @@ class BuildAllocationSerializer(serializers.Serializer): continue params = { - "build_line": build_line, - "stock_item": stock_item, - "install_into": output, + 'build_line': build_line, + 'stock_item': stock_item, + 'install_into': output, } try: @@ -1063,8 +1065,7 @@ class BuildAllocationSerializer(serializers.Serializer): else: # Create a new BuildItem to allocate stock build_item = BuildItem.objects.create( - quantity=quantity, - **params + quantity=quantity, **params ) except (ValidationError, DjangoValidationError) as exc: # Catch model errors and re-throw as DRF errors @@ -1075,7 +1076,8 @@ class BuildAutoAllocationSerializer(serializers.Serializer): """DRF serializer for auto allocating stock items against a build order.""" class Meta: - """Serializer metaclass""" + """Serializer metaclass.""" + fields = [ 'location', 'exclude_location', @@ -1090,7 +1092,9 @@ class BuildAutoAllocationSerializer(serializers.Serializer): allow_null=True, required=False, label=_('Source Location'), - help_text=_('Stock location where parts are to be sourced (leave blank to take from any location)'), + help_text=_( + 'Stock location where parts are to be sourced (leave blank to take from any location)' + ), ) exclude_location = serializers.PrimaryKeyRelatedField( @@ -1121,8 +1125,7 @@ class BuildAutoAllocationSerializer(serializers.Serializer): ) def save(self): - """Perform the auto-allocation step""" - + """Perform the auto-allocation step.""" import build.tasks import InvenTree.tasks @@ -1138,9 +1141,9 @@ class BuildAutoAllocationSerializer(serializers.Serializer): interchangeable=data['interchangeable'], substitutes=data['substitutes'], optional_items=data['optional_items'], - group='build' + group='build', ): - raise ValidationError(_("Failed to start auto-allocation task")) + raise ValidationError(_('Failed to start auto-allocation task')) class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer): @@ -1165,7 +1168,8 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali ] class Meta: - """Serializer metaclass""" + """Serializer metaclass.""" + model = BuildItem fields = [ 'pk', @@ -1175,14 +1179,12 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali 'stock_item', 'quantity', 'location', - # Detail fields, can be included or excluded 'build_detail', 'location_detail', 'part_detail', 'stock_item_detail', 'supplier_part_detail', - # The following fields are only used for data export 'bom_reference', 'bom_part_id', @@ -1202,7 +1204,7 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali ] def __init__(self, *args, **kwargs): - """Determine which extra details fields should be included""" + """Determine which extra details fields should be included.""" part_detail = kwargs.pop('part_detail', True) location_detail = kwargs.pop('location_detail', True) stock_detail = kwargs.pop('stock_detail', True) @@ -1223,55 +1225,103 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali self.fields.pop('build_detail', None) # Export-only fields - sku = serializers.CharField(source='stock_item.supplier_part.SKU', label=_('Supplier Part Number'), read_only=True) - mpn = serializers.CharField(source='stock_item.supplier_part.manufacturer_part.MPN', label=_('Manufacturer Part Number'), read_only=True) - location_name = serializers.CharField(source='stock_item.location.name', label=_('Location Name'), read_only=True) - build_reference = serializers.CharField(source='build.reference', label=_('Build Reference'), read_only=True) - bom_reference = serializers.CharField(source='build_line.bom_item.reference', label=_('BOM Reference'), read_only=True) - item_packaging = serializers.CharField(source='stock_item.packaging', label=_('Packaging'), read_only=True) + sku = serializers.CharField( + source='stock_item.supplier_part.SKU', + label=_('Supplier Part Number'), + read_only=True, + ) + mpn = serializers.CharField( + source='stock_item.supplier_part.manufacturer_part.MPN', + label=_('Manufacturer Part Number'), + read_only=True, + ) + location_name = serializers.CharField( + source='stock_item.location.name', label=_('Location Name'), read_only=True + ) + build_reference = serializers.CharField( + source='build.reference', label=_('Build Reference'), read_only=True + ) + bom_reference = serializers.CharField( + source='build_line.bom_item.reference', label=_('BOM Reference'), read_only=True + ) + item_packaging = serializers.CharField( + source='stock_item.packaging', label=_('Packaging'), read_only=True + ) # Part detail fields - part_id = serializers.PrimaryKeyRelatedField(source='stock_item.part', label=_('Part ID'), many=False, read_only=True) - part_name = serializers.CharField(source='stock_item.part.name', label=_('Part Name'), read_only=True) - part_ipn = serializers.CharField(source='stock_item.part.IPN', label=_('Part IPN'), read_only=True) - part_description = serializers.CharField(source='stock_item.part.description', label=_('Part Description'), read_only=True) + part_id = serializers.PrimaryKeyRelatedField( + source='stock_item.part', label=_('Part ID'), many=False, read_only=True + ) + part_name = serializers.CharField( + source='stock_item.part.name', label=_('Part Name'), read_only=True + ) + part_ipn = serializers.CharField( + source='stock_item.part.IPN', label=_('Part IPN'), read_only=True + ) + part_description = serializers.CharField( + source='stock_item.part.description', + label=_('Part Description'), + read_only=True, + ) # BOM Item Part ID (it may be different to the allocated part) - bom_part_id = serializers.PrimaryKeyRelatedField(source='build_line.bom_item.sub_part', label=_('BOM Part ID'), many=False, read_only=True) - bom_part_name = serializers.CharField(source='build_line.bom_item.sub_part.name', label=_('BOM Part Name'), read_only=True) + bom_part_id = serializers.PrimaryKeyRelatedField( + source='build_line.bom_item.sub_part', + label=_('BOM Part ID'), + many=False, + read_only=True, + ) + bom_part_name = serializers.CharField( + source='build_line.bom_item.sub_part.name', + label=_('BOM Part Name'), + read_only=True, + ) - item_batch_code = serializers.CharField(source='stock_item.batch', label=_('Batch Code'), read_only=True) - item_serial_number = serializers.CharField(source='stock_item.serial', label=_('Serial Number'), read_only=True) + item_batch_code = serializers.CharField( + source='stock_item.batch', label=_('Batch Code'), read_only=True + ) + item_serial_number = serializers.CharField( + source='stock_item.serial', label=_('Serial Number'), read_only=True + ) # Annotated fields - build = serializers.PrimaryKeyRelatedField(source='build_line.build', many=False, read_only=True) + build = serializers.PrimaryKeyRelatedField( + source='build_line.build', many=False, read_only=True + ) # Extra (optional) detail fields - part_detail = part_serializers.PartBriefSerializer(source='stock_item.part', many=False, read_only=True, pricing=False) + part_detail = part_serializers.PartBriefSerializer( + source='stock_item.part', many=False, read_only=True, pricing=False + ) stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True) - location = serializers.PrimaryKeyRelatedField(source='stock_item.location', many=False, read_only=True) - location_detail = LocationBriefSerializer(source='stock_item.location', read_only=True) - build_detail = BuildSerializer(source='build_line.build', many=False, read_only=True) - supplier_part_detail = company.serializers.SupplierPartSerializer(source='stock_item.supplier_part', many=False, read_only=True, brief=True) + location = serializers.PrimaryKeyRelatedField( + source='stock_item.location', many=False, read_only=True + ) + location_detail = LocationBriefSerializer( + source='stock_item.location', read_only=True + ) + build_detail = BuildSerializer( + source='build_line.build', many=False, read_only=True + ) + supplier_part_detail = company.serializers.SupplierPartSerializer( + source='stock_item.supplier_part', many=False, read_only=True, brief=True + ) quantity = InvenTreeDecimalField(label=_('Allocated Quantity')) - available_quantity = InvenTreeDecimalField(source='stock_item.quantity', read_only=True, label=_('Available Quantity')) + available_quantity = InvenTreeDecimalField( + source='stock_item.quantity', read_only=True, label=_('Available Quantity') + ) class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer): """Serializer for a BuildItem object.""" - export_exclude_fields = [ - 'allocations', - ] + export_exclude_fields = ['allocations'] - export_only_fields = [ - 'part_description', - 'part_category_name', - ] + export_only_fields = ['part_description', 'part_category_name'] class Meta: - """Serializer metaclass""" + """Serializer metaclass.""" model = BuildLine fields = [ @@ -1279,10 +1329,8 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali 'build', 'bom_item', 'quantity', - # Build detail fields 'build_reference', - # BOM item detail fields 'reference', 'consumable', @@ -1291,13 +1339,11 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali 'trackable', 'inherited', 'allow_variants', - # Part detail fields 'part', 'part_name', 'part_IPN', 'part_category_id', - # Annotated fields 'allocated', 'in_production', @@ -1306,28 +1352,21 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali 'available_substitute_stock', 'available_variant_stock', 'external_stock', - # Related fields 'allocations', - # Extra fields only for data export 'part_description', 'part_category_name', - # Extra detail (related field) serializers 'bom_item_detail', 'part_detail', 'build_detail', ] - read_only_fields = [ - 'build', - 'bom_item', - 'allocations', - ] + read_only_fields = ['build', 'bom_item', 'allocations'] def __init__(self, *args, **kwargs): - """Determine which extra details fields should be included""" + """Determine which extra details fields should be included.""" part_detail = kwargs.pop('part_detail', True) build_detail = kwargs.pop('build_detail', False) @@ -1340,27 +1379,59 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali self.fields.pop('build_detail', None) # Build info fields - build_reference = serializers.CharField(source='build.reference', label=_('Build Reference'), read_only=True) + build_reference = serializers.CharField( + source='build.reference', label=_('Build Reference'), read_only=True + ) # Part info fields - part = serializers.PrimaryKeyRelatedField(source='bom_item.sub_part', label=_('Part'), many=False, read_only=True) - part_name = serializers.CharField(source='bom_item.sub_part.name', label=_('Part Name'), read_only=True) - part_IPN = serializers.CharField(source='bom_item.sub_part.IPN', label=_('Part IPN'), read_only=True) + part = serializers.PrimaryKeyRelatedField( + source='bom_item.sub_part', label=_('Part'), many=False, read_only=True + ) + part_name = serializers.CharField( + source='bom_item.sub_part.name', label=_('Part Name'), read_only=True + ) + part_IPN = serializers.CharField( # noqa: N815 + source='bom_item.sub_part.IPN', label=_('Part IPN'), read_only=True + ) - part_description = serializers.CharField(source='bom_item.sub_part.description', label=_('Part Description'), read_only=True) - part_category_id = serializers.PrimaryKeyRelatedField(source='bom_item.sub_part.category', label=_('Part Category ID'), read_only=True) - part_category_name = serializers.CharField(source='bom_item.sub_part.category.name', label=_('Part Category Name'), read_only=True) + part_description = serializers.CharField( + source='bom_item.sub_part.description', + label=_('Part Description'), + read_only=True, + ) + part_category_id = serializers.PrimaryKeyRelatedField( + source='bom_item.sub_part.category', label=_('Part Category ID'), read_only=True + ) + part_category_name = serializers.CharField( + source='bom_item.sub_part.category.name', + label=_('Part Category Name'), + read_only=True, + ) allocations = BuildItemSerializer(many=True, read_only=True) # BOM item info fields - reference = serializers.CharField(source='bom_item.reference', label=_('Reference'), read_only=True) - consumable = serializers.BooleanField(source='bom_item.consumable', label=_('Consumable'), read_only=True) - optional = serializers.BooleanField(source='bom_item.optional', label=_('Optional'), read_only=True) - testable = serializers.BooleanField(source='bom_item.sub_part.testable', label=_('Testable'), read_only=True) - trackable = serializers.BooleanField(source='bom_item.sub_part.trackable', label=_('Trackable'), read_only=True) - inherited = serializers.BooleanField(source='bom_item.inherited', label=_('Inherited'), read_only=True) - allow_variants = serializers.BooleanField(source='bom_item.allow_variants', label=_('Allow Variants'), read_only=True) + reference = serializers.CharField( + source='bom_item.reference', label=_('Reference'), read_only=True + ) + consumable = serializers.BooleanField( + source='bom_item.consumable', label=_('Consumable'), read_only=True + ) + optional = serializers.BooleanField( + source='bom_item.optional', label=_('Optional'), read_only=True + ) + testable = serializers.BooleanField( + source='bom_item.sub_part.testable', label=_('Testable'), read_only=True + ) + trackable = serializers.BooleanField( + source='bom_item.sub_part.trackable', label=_('Trackable'), read_only=True + ) + inherited = serializers.BooleanField( + source='bom_item.inherited', label=_('Inherited'), read_only=True + ) + allow_variants = serializers.BooleanField( + source='bom_item.allow_variants', label=_('Allow Variants'), read_only=True + ) quantity = serializers.FloatField(label=_('Quantity')) @@ -1374,39 +1445,39 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali pricing=False, substitutes=False, sub_part_detail=False, - part_detail=False + part_detail=False, ) - part_detail = part_serializers.PartBriefSerializer(source='bom_item.sub_part', many=False, read_only=True, pricing=False) - build_detail = BuildSerializer(source='build', part_detail=False, many=False, read_only=True) + part_detail = part_serializers.PartBriefSerializer( + source='bom_item.sub_part', many=False, read_only=True, pricing=False + ) + build_detail = BuildSerializer( + source='build', part_detail=False, many=False, read_only=True + ) # Annotated (calculated) fields # Total quantity of allocated stock - allocated = serializers.FloatField( - label=_('Allocated Stock'), - read_only=True - ) + allocated = serializers.FloatField(label=_('Allocated Stock'), read_only=True) - on_order = serializers.FloatField( - label=_('On Order'), - read_only=True - ) + on_order = serializers.FloatField(label=_('On Order'), read_only=True) - in_production = serializers.FloatField( - label=_('In Production'), - read_only=True - ) + in_production = serializers.FloatField(label=_('In Production'), read_only=True) external_stock = serializers.FloatField(read_only=True, label=_('External Stock')) available_stock = serializers.FloatField(read_only=True, label=_('Available Stock')) - available_substitute_stock = serializers.FloatField(read_only=True, label=_('Available Substitute Stock')) - available_variant_stock = serializers.FloatField(read_only=True, label=_('Available Variant Stock')) + available_substitute_stock = serializers.FloatField( + read_only=True, label=_('Available Substitute Stock') + ) + available_variant_stock = serializers.FloatField( + read_only=True, label=_('Available Variant Stock') + ) @staticmethod def annotate_queryset(queryset, build=None): - """Add extra annotations to the queryset: + """Add extra annotations to the queryset. + Annotations: - allocated: Total stock quantity allocated against this build line - available: Total stock available for allocation against this build line - on_order: Total stock on order for this build line @@ -1427,7 +1498,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali 'bom_item__part', 'bom_item__part__pricing_data', 'bom_item__sub_part', - 'bom_item__sub_part__pricing_data' + 'bom_item__sub_part__pricing_data', ) # Pre-fetch related fields @@ -1436,11 +1507,9 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali 'allocations__stock_item', 'allocations__stock_item__part', 'allocations__stock_item__location', - 'bom_item__sub_part__stock_items', 'bom_item__sub_part__stock_items__allocations', 'bom_item__sub_part__stock_items__sales_order_allocations', - 'bom_item__substitutes', 'bom_item__substitutes__part__stock_items', 'bom_item__substitutes__part__stock_items__allocations', @@ -1449,58 +1518,60 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali # Defer expensive fields which we do not need for this serializer - queryset = queryset.defer( - 'build__lft', - 'build__rght', - 'build__level', - 'build__tree_id', - 'build__destination', - 'build__take_from', - 'build__completed_by', - 'build__issued_by', - 'build__sales_order', - 'build__parent', - 'build__notes', - 'build__metadata', - 'build__responsible', - 'build__barcode_data', - 'build__barcode_hash', - 'build__project_code', - ).defer( - 'bom_item__metadata' - ).defer( - 'bom_item__part__lft', - 'bom_item__part__rght', - 'bom_item__part__level', - 'bom_item__part__tree_id', - 'bom_item__part__tags', - 'bom_item__part__notes', - 'bom_item__part__variant_of', - 'bom_item__part__revision_of', - 'bom_item__part__creation_user', - 'bom_item__part__bom_checked_by', - 'bom_item__part__default_supplier', - 'bom_item__part__responsible_owner', - ).defer( - 'bom_item__sub_part__lft', - 'bom_item__sub_part__rght', - 'bom_item__sub_part__level', - 'bom_item__sub_part__tree_id', - 'bom_item__sub_part__tags', - 'bom_item__sub_part__notes', - 'bom_item__sub_part__variant_of', - 'bom_item__sub_part__revision_of', - 'bom_item__sub_part__creation_user', - 'bom_item__sub_part__bom_checked_by', - 'bom_item__sub_part__default_supplier', - 'bom_item__sub_part__responsible_owner', + queryset = ( + queryset.defer( + 'build__lft', + 'build__rght', + 'build__level', + 'build__tree_id', + 'build__destination', + 'build__take_from', + 'build__completed_by', + 'build__issued_by', + 'build__sales_order', + 'build__parent', + 'build__notes', + 'build__metadata', + 'build__responsible', + 'build__barcode_data', + 'build__barcode_hash', + 'build__project_code', + ) + .defer('bom_item__metadata') + .defer( + 'bom_item__part__lft', + 'bom_item__part__rght', + 'bom_item__part__level', + 'bom_item__part__tree_id', + 'bom_item__part__tags', + 'bom_item__part__notes', + 'bom_item__part__variant_of', + 'bom_item__part__revision_of', + 'bom_item__part__creation_user', + 'bom_item__part__bom_checked_by', + 'bom_item__part__default_supplier', + 'bom_item__part__responsible_owner', + ) + .defer( + 'bom_item__sub_part__lft', + 'bom_item__sub_part__rght', + 'bom_item__sub_part__level', + 'bom_item__sub_part__tree_id', + 'bom_item__sub_part__tags', + 'bom_item__sub_part__notes', + 'bom_item__sub_part__variant_of', + 'bom_item__sub_part__revision_of', + 'bom_item__sub_part__creation_user', + 'bom_item__sub_part__bom_checked_by', + 'bom_item__sub_part__default_supplier', + 'bom_item__sub_part__responsible_owner', + ) ) # Annotate the "allocated" quantity queryset = queryset.annotate( allocated=Coalesce( - Sum('allocations__quantity'), 0, - output_field=models.DecimalField() + Sum('allocations__quantity'), 0, output_field=models.DecimalField() ) ) @@ -1525,20 +1596,28 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali # Annotate the "on_order" quantity queryset = queryset.annotate( - on_order=part.filters.annotate_on_order_quantity(reference=ref), + on_order=part.filters.annotate_on_order_quantity(reference=ref) ) # Annotate the "available" quantity queryset = queryset.alias( - total_stock=part.filters.annotate_total_stock(reference=ref, filter=stock_filter), - allocated_to_sales_orders=part.filters.annotate_sales_order_allocations(reference=ref), - allocated_to_build_orders=part.filters.annotate_build_order_allocations(reference=ref), + total_stock=part.filters.annotate_total_stock( + reference=ref, filter=stock_filter + ), + 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(), ) ) @@ -1550,38 +1629,58 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali # Add 'external stock' annotations queryset = queryset.annotate( - external_stock=part.filters.annotate_total_stock(reference=ref, filter=external_stock_filter) + external_stock=part.filters.annotate_total_stock( + reference=ref, filter=external_stock_filter + ) ) ref = 'bom_item__substitutes__part__' # Extract similar information for any 'substitute' parts queryset = queryset.alias( - substitute_stock=part.filters.annotate_total_stock(reference=ref, filter=stock_filter), - substitute_build_allocations=part.filters.annotate_build_order_allocations(reference=ref), - substitute_sales_allocations=part.filters.annotate_sales_order_allocations(reference=ref) + substitute_stock=part.filters.annotate_total_stock( + reference=ref, filter=stock_filter + ), + 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(), ) ) # Annotate the queryset with 'available variant stock' information - variant_stock_query = part.filters.variant_stock_query(reference='bom_item__sub_part__', filter=stock_filter) + variant_stock_query = part.filters.variant_stock_query( + reference='bom_item__sub_part__', filter=stock_filter + ) 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(), ) ) diff --git a/src/backend/InvenTree/build/status_codes.py b/src/backend/InvenTree/build/status_codes.py index 1c8e66de30..75bf3945cc 100644 --- a/src/backend/InvenTree/build/status_codes.py +++ b/src/backend/InvenTree/build/status_codes.py @@ -24,6 +24,4 @@ class BuildStatusGroups: BuildStatus.PRODUCTION.value, ] - COMPLETE = [ - BuildStatus.COMPLETE.value, - ] + COMPLETE = [BuildStatus.COMPLETE.value] diff --git a/src/backend/InvenTree/build/tasks.py b/src/backend/InvenTree/build/tasks.py index 995a897c5f..0cd00c2329 100644 --- a/src/backend/InvenTree/build/tasks.py +++ b/src/backend/InvenTree/build/tasks.py @@ -3,13 +3,12 @@ import logging import random import time - from datetime import timedelta from decimal import Decimal from django.contrib.auth.models import User -from django.template.loader import render_to_string from django.db import transaction +from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ from allauth.account.models import EmailAddress @@ -34,7 +33,10 @@ def auto_allocate_build(build_id: int, **kwargs): build_order = build_models.Build.objects.filter(pk=build_id).first() if not build_order: - logger.warning("Could not auto-allocate BuildOrder <%s> - BuildOrder does not exist", build_id) + logger.warning( + 'Could not auto-allocate BuildOrder <%s> - BuildOrder does not exist', + build_id, + ) return build_order.auto_allocate_stock(**kwargs) @@ -48,13 +50,19 @@ def complete_build_allocations(build_id: int, user_id: int): try: user = User.objects.get(pk=user_id) except User.DoesNotExist: - logger.warning("Could not complete build allocations for BuildOrder <%s> - User does not exist", build_id) + logger.warning( + 'Could not complete build allocations for BuildOrder <%s> - User does not exist', + build_id, + ) return else: user = None if not build_order: - logger.warning("Could not complete build allocations for BuildOrder <%s> - BuildOrder does not exist", build_id) + logger.warning( + 'Could not complete build allocations for BuildOrder <%s> - BuildOrder does not exist', + build_id, + ) return build_order.complete_allocations(user) @@ -65,7 +73,7 @@ def update_build_order_lines(bom_item_pk: int): This task is triggered when a BomItem is created or updated. """ - logger.info("Updating build order lines for BomItem %s", bom_item_pk) + logger.info('Updating build order lines for BomItem %s', bom_item_pk) bom_item = part_models.BomItem.objects.filter(pk=bom_item_pk).first() @@ -77,16 +85,14 @@ def update_build_order_lines(bom_item_pk: int): # Find all active builds which reference any of the parts builds = build_models.Build.objects.filter( - part__in=list(assemblies), - status__in=BuildStatusGroups.ACTIVE_CODES + part__in=list(assemblies), status__in=BuildStatusGroups.ACTIVE_CODES ) # Iterate through each build, and update the relevant line items for bo in builds: # Try to find a matching build order line line = build_models.BuildLine.objects.filter( - build=bo, - bom_item=bom_item, + build=bo, bom_item=bom_item ).first() q = bom_item.get_required_quantity(bo.quantity) @@ -99,13 +105,13 @@ def update_build_order_lines(bom_item_pk: int): else: # Create a new line item build_models.BuildLine.objects.create( - build=bo, - bom_item=bom_item, - quantity=q, + build=bo, bom_item=bom_item, quantity=q ) if builds.count() > 0: - logger.info("Updated %s build orders for part %s", builds.count(), bom_item.part) + logger.info( + 'Updated %s build orders for part %s', builds.count(), bom_item.part + ) def check_build_stock(build: build_models.Build): @@ -133,7 +139,6 @@ def check_build_stock(build: build_models.Build): return for bom_item in part.get_bom_items(): - sub_part = bom_item.sub_part # The 'in stock' quantity depends on whether the bom_item allows variants @@ -149,7 +154,9 @@ def check_build_stock(build: build_models.Build): # There is not sufficient stock for this part lines.append({ - 'link': InvenTree.helpers_model.construct_absolute_url(sub_part.get_absolute_url()), + 'link': InvenTree.helpers_model.construct_absolute_url( + sub_part.get_absolute_url() + ), 'part': sub_part, 'in_stock': in_stock, 'allocated': allocated, @@ -164,29 +171,32 @@ def check_build_stock(build: build_models.Build): # Are there any users subscribed to these parts? subscribers = build.part.get_subscribers() - emails = EmailAddress.objects.filter( - user__in=subscribers, - ) + emails = EmailAddress.objects.filter(user__in=subscribers) if len(emails) > 0: - - logger.info("Notifying users of stock required for build %s", build.pk) + logger.info('Notifying users of stock required for build %s', build.pk) context = { - 'link': InvenTree.helpers_model.construct_absolute_url(build.get_absolute_url()), + 'link': InvenTree.helpers_model.construct_absolute_url( + build.get_absolute_url() + ), 'build': build, 'part': build.part, 'lines': lines, } # Render the HTML message - html_message = render_to_string('email/build_order_required_stock.html', context) + html_message = render_to_string( + 'email/build_order_required_stock.html', context + ) - subject = _("Stock required for build order") + subject = _('Stock required for build order') recipients = emails.values_list('email', flat=True) - InvenTree.helpers_email.send_email(subject, '', recipients, html_message=html_message) + InvenTree.helpers_email.send_email( + subject, '', recipients, html_message=html_message + ) def create_child_builds(build_id: int) -> None: @@ -195,7 +205,6 @@ def create_child_builds(build_id: int) -> None: - Will create a build order for each assembly part in the BOM - Runs recursively, also creating child builds for each sub-assembly part """ - try: build_order = build_models.Build.objects.get(pk=build_id) except (build_models.Build.DoesNotExist, ValueError): @@ -215,13 +224,12 @@ def create_child_builds(build_id: int) -> None: for item in assembly_items: quantity = item.quantity * build_order.quantity - # Check if the child build order has already been created if build_models.Build.objects.filter( part=item.sub_part, parent=build_order, quantity=quantity, - status__in=BuildStatusGroups.ACTIVE_CODES + status__in=BuildStatusGroups.ACTIVE_CODES, ).exists(): continue @@ -241,11 +249,7 @@ def create_child_builds(build_id: int) -> None: for pk in sub_build_ids: # Offload the child build order creation to the background task queue - InvenTree.tasks.offload_task( - create_child_builds, - pk, - group='build' - ) + InvenTree.tasks.offload_task(create_child_builds, pk, group='build') def notify_overdue_build_order(bo: build_models.Build): @@ -263,24 +267,16 @@ def notify_overdue_build_order(bo: build_models.Build): context = { 'order': bo, 'name': name, - 'message': _(f"Build order {bo} is now overdue"), - 'link': InvenTree.helpers_model.construct_absolute_url( - bo.get_absolute_url(), - ), - 'template': { - 'html': 'email/overdue_build_order.html', - 'subject': name, - } + 'message': _(f'Build order {bo} is now overdue'), + 'link': InvenTree.helpers_model.construct_absolute_url(bo.get_absolute_url()), + 'template': {'html': 'email/overdue_build_order.html', 'subject': name}, } event_name = BuildEvents.OVERDUE # Send a notification to the appropriate users common.notifications.trigger_notification( - bo, - event_name, - targets=targets, - context=context + bo, event_name, targets=targets, context=context ) # Register a matching event to the plugin system @@ -298,8 +294,7 @@ def check_overdue_build_orders(): yesterday = InvenTree.helpers.current_date() - timedelta(days=1) overdue_orders = build_models.Build.objects.filter( - target_date=yesterday, - status__in=BuildStatusGroups.ACTIVE_CODES + target_date=yesterday, status__in=BuildStatusGroups.ACTIVE_CODES ) for bo in overdue_orders: diff --git a/src/backend/InvenTree/build/test_api.py b/src/backend/InvenTree/build/test_api.py index 07555cb63e..887e743292 100644 --- a/src/backend/InvenTree/build/test_api.py +++ b/src/backend/InvenTree/build/test_api.py @@ -1,4 +1,4 @@ -"""Unit tests for the BuildOrder API""" +"""Unit tests for the BuildOrder API.""" from datetime import datetime, timedelta @@ -6,13 +6,12 @@ from django.urls import reverse from rest_framework import status -from part.models import Part, BomItem from build.models import Build, BuildItem, BuildLine -from stock.models import StockItem - from build.status_codes import BuildStatus -from stock.status_codes import StockStatus from InvenTree.unit_test import InvenTreeAPITestCase +from part.models import BomItem, Part +from stock.models import StockItem +from stock.status_codes import StockStatus class TestBuildAPI(InvenTreeAPITestCase): @@ -22,18 +21,9 @@ class TestBuildAPI(InvenTreeAPITestCase): - Tests for BuildItem API """ - fixtures = [ - 'category', - 'part', - 'location', - 'build', - ] + fixtures = ['category', 'part', 'location', 'build'] - roles = [ - 'build.change', - 'build.add', - 'build.delete', - ] + roles = ['build.change', 'build.add', 'build.delete'] def test_get_build_list(self): """Test that we can retrieve list of build objects.""" @@ -45,7 +35,9 @@ class TestBuildAPI(InvenTreeAPITestCase): self.assertEqual(len(response.data), 5) # Filter query by build status - response = self.get(url, {'status': BuildStatus.COMPLETE.value}, expected_code=200) + response = self.get( + url, {'status': BuildStatus.COMPLETE.value}, expected_code=200 + ) self.assertEqual(len(response.data), 4) @@ -88,27 +80,17 @@ class TestBuildAPI(InvenTreeAPITestCase): class BuildAPITest(InvenTreeAPITestCase): """Series of tests for the Build DRF API.""" - fixtures = [ - 'category', - 'part', - 'location', - 'bom', - 'build', - 'stock', - ] + fixtures = ['category', 'part', 'location', 'bom', 'build', 'stock'] # Required roles to access Build API endpoints - roles = [ - 'build.change', - 'build.add', - ] + roles = ['build.change', 'build.add'] class BuildTest(BuildAPITest): """Unit testing for the build complete API endpoint.""" def setUp(self): - """Basic setup for this test suite""" + """Basic setup for this test suite.""" super().setUp() self.build = Build.objects.get(pk=1) @@ -121,79 +103,47 @@ class BuildTest(BuildAPITest): self.post( reverse('api-build-output-complete', kwargs={'pk': 99999}), {}, - expected_code=400 + expected_code=400, ) data = self.post(self.url, {}, expected_code=400).data - self.assertIn("This field is required", str(data['outputs'])) - self.assertIn("This field is required", str(data['location'])) + self.assertIn('This field is required', str(data['outputs'])) + self.assertIn('This field is required', str(data['location'])) # Test with an invalid location data = self.post( - self.url, - { - "outputs": [], - "location": 999999, - }, - expected_code=400 + self.url, {'outputs': [], 'location': 999999}, expected_code=400 ).data - self.assertIn( - "Invalid pk", - str(data["location"]) - ) + self.assertIn('Invalid pk', str(data['location'])) data = self.post( - self.url, - { - "outputs": [], - "location": 1, - }, - expected_code=400 + self.url, {'outputs': [], 'location': 1}, expected_code=400 ).data - self.assertIn("A list of build outputs must be provided", str(data)) + self.assertIn('A list of build outputs must be provided', str(data)) - stock_item = StockItem.objects.create( - part=self.build.part, - quantity=100, - ) + stock_item = StockItem.objects.create(part=self.build.part, quantity=100) - post_data = { - "outputs": [ - { - "output": stock_item.pk, - }, - ], - "location": 1, - } + post_data = {'outputs': [{'output': stock_item.pk}], 'location': 1} # Post with a stock item that does not match the build - data = self.post( - self.url, - post_data, - expected_code=400 - ).data + data = self.post(self.url, post_data, expected_code=400).data self.assertIn( - "Build output does not match the parent build", - str(data["outputs"][0]) + 'Build output does not match the parent build', str(data['outputs'][0]) ) # Now, ensure that the stock item *does* match the build stock_item.build = self.build stock_item.save() - data = self.post( - self.url, - post_data, - expected_code=400, - ).data + data = self.post(self.url, post_data, expected_code=400).data self.assertIn( - "This build output has already been completed", - str(data["outputs"][0]["output"]) + 'This build output has already been completed', + str(data['outputs'][0]['output']), ) def test_complete(self): @@ -219,9 +169,9 @@ class BuildTest(BuildAPITest): self.post( self.url, { - "outputs": [{"output": output.pk} for output in outputs], - "location": 1, - "status": StockStatus.ATTENTION.value, + 'outputs': [{'output': output.pk} for output in outputs], + 'location': 1, + 'status': StockStatus.ATTENTION.value, }, expected_code=201, max_query_count=600, # TODO: Try to optimize this @@ -243,22 +193,12 @@ class BuildTest(BuildAPITest): # Try to complete the build (it should fail) finish_url = reverse('api-build-finish', kwargs={'pk': self.build.pk}) - response = self.post( - finish_url, - {}, - expected_code=400 - ) + response = self.post(finish_url, {}, expected_code=400) self.assertIn('accept_unallocated', response.data) # Accept unallocated stock - self.post( - finish_url, - { - 'accept_unallocated': True, - }, - expected_code=201, - ) + self.post(finish_url, {'accept_unallocated': True}, expected_code=201) self.build.refresh_from_db() @@ -274,16 +214,10 @@ class BuildTest(BuildAPITest): def make_new_build(ref): """Make a new build order, and allocate stock to it.""" - data = self.post( reverse('api-build-list'), - { - 'part': 100, - 'quantity': 10, - 'title': 'Test build', - 'reference': ref, - }, - expected_code=201 + {'part': 100, 'quantity': 10, 'title': 'Test build', 'reference': ref}, + expected_code=201, ).data build = Build.objects.get(pk=data['pk']) @@ -324,33 +258,24 @@ class BuildTest(BuildAPITest): self.assertGreater(bo.consumed_stock.count(), 0) def test_delete(self): - """Test that we can delete a BuildOrder via the API""" + """Test that we can delete a BuildOrder via the API.""" bo = Build.objects.get(pk=1) url = reverse('api-build-detail', kwargs={'pk': bo.pk}) # At first we do not have the required permissions - self.delete( - url, - expected_code=403, - ) + self.delete(url, expected_code=403) self.assignRole('build.delete') # As build is currently not 'cancelled', it cannot be deleted - self.delete( - url, - expected_code=400, - ) + self.delete(url, expected_code=400) bo.status = BuildStatus.CANCELLED.value bo.save() # Now, we should be able to delete - self.delete( - url, - expected_code=204, - ) + self.delete(url, expected_code=204) with self.assertRaises(Build.DoesNotExist): Build.objects.get(pk=1) @@ -365,56 +290,43 @@ class BuildTest(BuildAPITest): # Attempt to create outputs with invalid data response = self.post( - create_url, - { - 'quantity': 'not a number', - }, - expected_code=400 + create_url, {'quantity': 'not a number'}, expected_code=400 ) self.assertIn('A valid number is required', str(response.data)) for q in [-100, -10.3, 0]: - - response = self.post( - create_url, - { - 'quantity': q, - }, - expected_code=400 - ) + response = self.post(create_url, {'quantity': q}, expected_code=400) if q == 0: self.assertIn('Quantity must be greater than zero', str(response.data)) else: - self.assertIn('Ensure this value is greater than or equal to 0', str(response.data)) + self.assertIn( + 'Ensure this value is greater than or equal to 0', + str(response.data), + ) # Mark the part being built as 'trackable' (requires integer quantity) bo.part.trackable = True bo.part.save() - response = self.post( - create_url, - { - 'quantity': 12.3, - }, - expected_code=400 - ) + response = self.post(create_url, {'quantity': 12.3}, expected_code=400) - self.assertIn('Integer quantity required for trackable parts', str(response.data)) + self.assertIn( + 'Integer quantity required for trackable parts', str(response.data) + ) # Erroneous serial numbers response = self.post( create_url, - { - 'quantity': 5, - 'serial_numbers': '1, 2, 3, 4, 5, 6', - 'batch': 'my-batch', - }, - expected_code=400 + {'quantity': 5, 'serial_numbers': '1, 2, 3, 4, 5, 6', 'batch': 'my-batch'}, + expected_code=400, ) - self.assertIn('Number of unique serial numbers (6) must match quantity (5)', str(response.data)) + self.assertIn( + 'Number of unique serial numbers (6) must match quantity (5)', + str(response.data), + ) # At this point, no new build outputs should have been created self.assertEqual(n_outputs, bo.output_count) @@ -422,11 +334,7 @@ class BuildTest(BuildAPITest): # Now, create with *good* data self.post( create_url, - { - 'quantity': 5, - 'serial_numbers': '1, 2, 3, 4, 5', - 'batch': 'my-batch', - }, + {'quantity': 5, 'serial_numbers': '1, 2, 3, 4, 5', 'batch': 'my-batch'}, expected_code=201, ) @@ -435,15 +343,13 @@ class BuildTest(BuildAPITest): # Attempt to create with identical serial numbers response = self.post( - create_url, - { - 'quantity': 3, - 'serial_numbers': '1-3', - }, - expected_code=400, + create_url, {'quantity': 3, 'serial_numbers': '1-3'}, expected_code=400 ) - self.assertIn('The following serial numbers already exist or are invalid : 1,2,3', str(response.data)) + self.assertIn( + 'The following serial numbers already exist or are invalid : 1,2,3', + str(response.data), + ) # Double check no new outputs have been created self.assertEqual(n_outputs + 5, bo.output_count) @@ -457,13 +363,7 @@ class BuildTest(BuildAPITest): delete_url = reverse('api-build-output-delete', kwargs={'pk': 1}) - response = self.post( - delete_url, - { - 'outputs': [], - }, - expected_code=400 - ) + response = self.post(delete_url, {'outputs': []}, expected_code=400) self.assertIn('A list of build outputs must be provided', str(response.data)) @@ -477,17 +377,13 @@ class BuildTest(BuildAPITest): # Note: One has been completed, so this should fail! response = self.post( delete_url, - { - 'outputs': [ - { - 'output': output.pk, - } for output in outputs - ] - }, - expected_code=400 + {'outputs': [{'output': output.pk} for output in outputs]}, + expected_code=400, ) - self.assertIn('This build output has already been completed', str(response.data)) + self.assertIn( + 'This build output has already been completed', str(response.data) + ) # No change to the build outputs self.assertEqual(n_outputs + 5, bo.output_count) @@ -496,14 +392,8 @@ class BuildTest(BuildAPITest): # Let's delete 2 build outputs self.post( delete_url, - { - 'outputs': [ - { - 'output': output.pk, - } for output in outputs[1:3] - ] - }, - expected_code=201 + {'outputs': [{'output': output.pk} for output in outputs[1:3]]}, + expected_code=201, ) # Two build outputs have been removed @@ -515,12 +405,7 @@ class BuildTest(BuildAPITest): # Let's mark the remaining outputs as complete response = self.post( - complete_url, - { - 'outputs': [], - 'location': 4, - }, - expected_code=400, + complete_url, {'outputs': [], 'location': 4}, expected_code=400 ) self.assertIn('A list of build outputs must be provided', str(response.data)) @@ -532,11 +417,7 @@ class BuildTest(BuildAPITest): self.post( complete_url, { - 'outputs': [ - { - 'output': output.pk - } for output in outputs[3:] - ], + 'outputs': [{'output': output.pk} for output in outputs[3:]], 'location': 4, }, expected_code=201, @@ -553,20 +434,16 @@ class BuildTest(BuildAPITest): # Try again, with an output which has already been completed response = self.post( complete_url, - { - 'outputs': [ - { - 'output': outputs.last().pk, - } - ] - }, + {'outputs': [{'output': outputs.last().pk}]}, expected_code=400, ) - self.assertIn('This build output has already been completed', str(response.data)) + self.assertIn( + 'This build output has already been completed', str(response.data) + ) def test_download_build_orders(self): - """Test that we can download a list of build orders via the API""" + """Test that we can download a list of build orders via the API.""" required_cols = [ 'Reference', 'Build Status', @@ -580,27 +457,17 @@ class BuildTest(BuildAPITest): 'Quantity', ] - excluded_cols = [ - 'lft', 'rght', 'tree_id', 'level', - 'metadata', - ] - - with self.download_file( - reverse('api-build-list'), - { - 'export': 'csv', - } - ) as file: + excluded_cols = ['lft', 'rght', 'tree_id', 'level', 'metadata'] + with self.download_file(reverse('api-build-list'), {'export': 'csv'}) as file: data = self.process_csv( file, required_cols=required_cols, excluded_cols=excluded_cols, - required_rows=Build.objects.count() + required_rows=Build.objects.count(), ) for row in data: - build = Build.objects.get(pk=row['ID']) self.assertEqual(str(build.part.pk), row['Part']) @@ -611,27 +478,24 @@ class BuildTest(BuildAPITest): def test_create(self): """Test creation of new build orders via the API.""" - url = reverse('api-build-list') # First, we'll create a tree of part assemblies - part_a = Part.objects.create(name="Part A", description="Part A description", assembly=True) - part_b = Part.objects.create(name="Part B", description="Part B description", assembly=True) - part_c = Part.objects.create(name="Part C", description="Part C description", assembly=True) + part_a = Part.objects.create( + name='Part A', description='Part A description', assembly=True + ) + part_b = Part.objects.create( + name='Part B', description='Part B description', assembly=True + ) + part_c = Part.objects.create( + name='Part C', description='Part C description', assembly=True + ) # Create a BOM for Part A - BomItem.objects.create( - part=part_a, - sub_part=part_b, - quantity=5, - ) + BomItem.objects.create(part=part_a, sub_part=part_b, quantity=5) # Create a BOM for Part B - BomItem.objects.create( - part=part_b, - sub_part=part_c, - quantity=7 - ) + BomItem.objects.create(part=part_b, sub_part=part_c, quantity=7) n = Build.objects.count() @@ -644,7 +508,7 @@ class BuildTest(BuildAPITest): 'quantity': 10, 'title': 'A build', }, - expected_code=201 + expected_code=201, ) self.assertEqual(n + 1, Build.objects.count()) @@ -662,7 +526,7 @@ class BuildTest(BuildAPITest): 'quantity': 15, 'title': 'A build - with childs', 'create_child_builds': True, - } + }, ) # An addition 1 + 2 builds should have been created @@ -694,7 +558,7 @@ class BuildAllocationTest(BuildAPITest): """ def setUp(self): - """Basic operation as part of test suite setup""" + """Basic operation as part of test suite setup.""" super().setUp() self.assignRole('build.add') @@ -718,7 +582,9 @@ class BuildAllocationTest(BuildAPITest): self.assertEqual(self.build.part.bom_items.count(), 4) # No items yet allocated to this build - self.assertEqual(BuildItem.objects.filter(build_line__build=self.build).count(), 0) + self.assertEqual( + BuildItem.objects.filter(build_line__build=self.build).count(), 0 + ) def test_get(self): """A GET request to the endpoint should return an error.""" @@ -728,7 +594,9 @@ class BuildAllocationTest(BuildAPITest): """An OPTIONS request to the endpoint should return information about the endpoint.""" response = self.options(self.url, expected_code=200) - self.assertIn("API endpoint to allocate stock items to a build order", str(response.data)) + self.assertIn( + 'API endpoint to allocate stock items to a build order', str(response.data) + ) def test_empty(self): """Test without any POST data.""" @@ -738,13 +606,7 @@ class BuildAllocationTest(BuildAPITest): self.assertIn('This field is required', str(data['items'])) # Now test but with an empty items list - data = self.post( - self.url, - { - "items": [] - }, - expected_code=400 - ).data + data = self.post(self.url, {'items': []}, expected_code=400).data self.assertIn('Allocation items must be provided', str(data)) @@ -757,49 +619,35 @@ class BuildAllocationTest(BuildAPITest): data = self.post( self.url, { - "items": [ + 'items': [ { - "build_line": 1, # M2x4 LPHS - "stock_item": 2, # 5,000 screws available + 'build_line': 1, # M2x4 LPHS + 'stock_item': 2, # 5,000 screws available } ] }, - expected_code=400 + expected_code=400, ).data - self.assertIn('This field is required', str(data["items"][0]["quantity"])) + self.assertIn('This field is required', str(data['items'][0]['quantity'])) # Missing bom_item data = self.post( self.url, - { - "items": [ - { - "stock_item": 2, - "quantity": 5000, - } - ] - }, - expected_code=400 + {'items': [{'stock_item': 2, 'quantity': 5000}]}, + expected_code=400, ).data - self.assertIn("This field is required", str(data["items"][0]["build_line"])) + self.assertIn('This field is required', str(data['items'][0]['build_line'])) # Missing stock_item data = self.post( self.url, - { - "items": [ - { - "build_line": 1, - "quantity": 5000, - } - ] - }, - expected_code=400 + {'items': [{'build_line': 1, 'quantity': 5000}]}, + expected_code=400, ).data - self.assertIn("This field is required", str(data["items"][0]["stock_item"])) + self.assertIn('This field is required', str(data['items'][0]['stock_item'])) # No new BuildItem objects have been created during this test self.assertEqual(self.n, BuildItem.objects.count()) @@ -820,15 +668,11 @@ class BuildAllocationTest(BuildAPITest): data = self.post( self.url, { - "items": [ - { - "build_line": wrong_line.pk, - "stock_item": 11, - "quantity": 500, - } + 'items': [ + {'build_line': wrong_line.pk, 'stock_item': 11, 'quantity': 500} ] }, - expected_code=400 + expected_code=400, ).data self.assertIn('Selected stock item does not match BOM line', str(data)) @@ -844,7 +688,6 @@ class BuildAllocationTest(BuildAPITest): right_line = None for line in self.build.build_lines.all(): - if line.bom_item.sub_part.pk == si.part.pk: right_line = line break @@ -852,15 +695,11 @@ class BuildAllocationTest(BuildAPITest): self.post( self.url, { - "items": [ - { - "build_line": right_line.pk, - "stock_item": 2, - "quantity": 5000, - } + 'items': [ + {'build_line': right_line.pk, 'stock_item': 2, 'quantity': 5000} ] }, - expected_code=201 + expected_code=201, ) # A new BuildItem should have been created @@ -889,15 +728,11 @@ class BuildAllocationTest(BuildAPITest): self.post( self.url, { - "items": [ - { - "build_line": right_line.pk, - "stock_item": 2, - "quantity": 3000, - } + 'items': [ + {'build_line': right_line.pk, 'stock_item': 2, 'quantity': 3000} ] }, - expected_code=201 + expected_code=201, ) # A new BuildItem should have been created @@ -913,15 +748,11 @@ class BuildAllocationTest(BuildAPITest): self.post( self.url, { - "items": [ - { - "build_line": right_line.pk, - "stock_item": 2, - "quantity": 2001, - } + 'items': [ + {'build_line': right_line.pk, 'stock_item': 2, 'quantity': 2001} ] }, - expected_code=400 + expected_code=400, ) allocation.refresh_from_db() @@ -931,15 +762,11 @@ class BuildAllocationTest(BuildAPITest): self.post( self.url, { - "items": [ - { - "build_line": right_line.pk, - "stock_item": 2, - "quantity": 2000, - } + 'items': [ + {'build_line': right_line.pk, 'stock_item': 2, 'quantity': 2000} ] }, - expected_code=201 + expected_code=201, ) allocation.refresh_from_db() @@ -950,7 +777,6 @@ class BuildAllocationTest(BuildAPITest): Ref: https://github.com/inventree/InvenTree/issues/6508 """ - si = StockItem.objects.get(pk=2) # Find line item @@ -963,40 +789,29 @@ class BuildAllocationTest(BuildAPITest): self.post( self.url, { - "items": [ - { - "build_line": line.pk, - "stock_item": si.pk, - "quantity": 0.1616, - } + 'items': [ + {'build_line': line.pk, 'stock_item': si.pk, 'quantity': 0.1616} ] }, - expected_code=201 + expected_code=201, ) # Test a fractional quantity when the *available* quantity is less than 1 si = StockItem.objects.create( - part=si.part, - quantity=0.3159, - tree_id=0, - level=0, - lft=0, rght=0 + part=si.part, quantity=0.3159, tree_id=0, level=0, lft=0, rght=0 ) self.post( self.url, { - "items": [ - { - "build_line": line.pk, - "stock_item": si.pk, - "quantity": 0.1616, - } + 'items': [ + {'build_line': line.pk, 'stock_item': si.pk, 'quantity': 0.1616} ] }, expected_code=201, ) + class BuildItemTest(BuildAPITest): """Unit tests for build items. @@ -1008,7 +823,7 @@ class BuildItemTest(BuildAPITest): """ def setUp(self): - """Basic operation as part of test suite setup""" + """Basic operation as part of test suite setup.""" super().setUp() self.assignRole('build.add') @@ -1024,7 +839,6 @@ class BuildItemTest(BuildAPITest): def test_update_overallocated(self): """Test update of overallocated stock items.""" - si = StockItem.objects.get(pk=2) # Find line item @@ -1035,11 +849,7 @@ class BuildItemTest(BuildAPITest): si.save() # Create build item - bi = BuildItem( - build_line=line, - stock_item=si, - quantity=100 - ) + bi = BuildItem(build_line=line, stock_item=si, quantity=100) bi.save() # Reduce stock item quantity @@ -1049,13 +859,8 @@ class BuildItemTest(BuildAPITest): # Reduce build item quantity url = reverse('api-build-item-detail', kwargs={'pk': bi.pk}) - self.patch( - url, - { - "quantity": 50, - }, - expected_code=200, - ) + self.patch(url, {'quantity': 50}, expected_code=200) + class BuildOverallocationTest(BuildAPITest): """Unit tests for over allocation of stock items against a build order. @@ -1065,7 +870,7 @@ class BuildOverallocationTest(BuildAPITest): @classmethod def setUpTestData(cls): - """Basic operation as part of test suite setup""" + """Basic operation as part of test suite setup.""" super().setUpTestData() cls.assignRole('build.add') @@ -1089,11 +894,9 @@ class BuildOverallocationTest(BuildAPITest): cls.state[sub_part] = (si, si.quantity, required) - items_to_create.append(BuildItem( - build_line=build_line, - stock_item=si, - quantity=required, - )) + items_to_create.append( + BuildItem(build_line=build_line, stock_item=si, quantity=required) + ) BuildItem.objects.bulk_create(items_to_create) @@ -1103,7 +906,7 @@ class BuildOverallocationTest(BuildAPITest): cls.build.complete_build_output(outputs[0], cls.user) def setUp(self): - """Basic operation as part of test suite setup""" + """Basic operation as part of test suite setup.""" super().setUp() self.generate_exchange_rates() @@ -1117,11 +920,7 @@ class BuildOverallocationTest(BuildAPITest): def test_overallocated_requires_acceptance(self): """Test build order cannot complete with overallocated items.""" # Try to complete the build (it should fail due to overallocation) - response = self.post( - self.url, - {}, - expected_code=400 - ) + response = self.post(self.url, {}, expected_code=400) self.assertIn('accept_overallocated', response.data) # Check stock items have not reduced at all @@ -1132,9 +931,7 @@ class BuildOverallocationTest(BuildAPITest): # Accept overallocated stock self.post( self.url, - { - 'accept_overallocated': 'accept', - }, + {'accept_overallocated': 'accept'}, expected_code=201, max_query_count=1000, # TODO: Come back and refactor this ) @@ -1153,9 +950,7 @@ class BuildOverallocationTest(BuildAPITest): """Test build order will trim/de-allocate overallocated stock when requested.""" self.post( self.url, - { - 'accept_overallocated': 'trim', - }, + {'accept_overallocated': 'trim'}, expected_code=201, max_query_count=1000, # TODO: Come back and refactor this ) @@ -1169,7 +964,6 @@ class BuildOverallocationTest(BuildAPITest): # Check stock items have reduced only by bom requirement (overallocation trimmed) for line in self.build.build_lines.all(): - si, oq, _ = self.state[line.bom_item.sub_part] rq = line.quantity si.refresh_from_db() @@ -1207,11 +1001,11 @@ class BuildListTest(BuildAPITest): Build.objects.create( part=part, - reference="BO-0006", + reference='BO-0006', quantity=10, title='Just some thing', status=BuildStatus.PRODUCTION.value, - target_date=in_the_past + target_date=in_the_past, ) response = self.get(self.url, data={'overdue': True}) @@ -1233,24 +1027,22 @@ class BuildListTest(BuildAPITest): Build.objects.create( part=part, quantity=10, - reference=f"BO-{i + 10}", - title=f"Sub build {i}", - parent=parent + reference=f'BO-{i + 10}', + title=f'Sub build {i}', + parent=parent, ) # And some sub-sub builds for ii, sub_build in enumerate(Build.objects.filter(parent=parent)): - for i in range(3): - x = ii * 10 + i + 50 Build.objects.create( part=part, - reference=f"BO-{x}", - title=f"{sub_build.reference}-00{i}-sub", + reference=f'BO-{x}', + title=f'{sub_build.reference}-00{i}-sub', quantity=40, - parent=sub_build + parent=sub_build, ) # 20 new builds should have been created! @@ -1278,7 +1070,6 @@ class BuildOutputCreateTest(BuildAPITest): def test_create_serialized_output(self): """Create a serialized build output via the API.""" - build_id = 1 url = reverse('api-build-output-create', kwargs={'pk': build_id}) @@ -1291,15 +1082,13 @@ class BuildOutputCreateTest(BuildAPITest): # Post with invalid data response = self.post( - url, - data={ - 'quantity': 10, - 'serial_numbers': '1-100', - }, - expected_code=400 + url, data={'quantity': 10, 'serial_numbers': '1-100'}, expected_code=400 ) - self.assertIn('Group range 1-100 exceeds allowed quantity (10)', str(response.data['serial_numbers'])) + self.assertIn( + 'Group range 1-100 exceeds allowed quantity (10)', + str(response.data['serial_numbers']), + ) # Build outputs have not increased self.assertEqual(n_outputs, build.output_count) @@ -1308,12 +1097,7 @@ class BuildOutputCreateTest(BuildAPITest): self.assertEqual(n_items, part.stock_items.count()) response = self.post( - url, - data={ - 'quantity': 5, - 'serial_numbers': '1,2,3-5', - }, - expected_code=201 + url, data={'quantity': 5, 'serial_numbers': '1,2,3-5'}, expected_code=201 ) # Build outputs have incdeased @@ -1328,7 +1112,6 @@ class BuildOutputCreateTest(BuildAPITest): def test_create_unserialized_output(self): """Create an unserialized build output via the API.""" - build_id = 1 url = reverse('api-build-output-create', kwargs={'pk': build_id}) @@ -1339,13 +1122,7 @@ class BuildOutputCreateTest(BuildAPITest): n_items = part.stock_items.count() # Create a single new output - self.post( - url, - data={ - 'quantity': 10, - }, - expected_code=201 - ) + self.post(url, data={'quantity': 10}, expected_code=201) # Build outputs have increased self.assertEqual(n_outputs + 1, build.output_count) @@ -1353,11 +1130,12 @@ class BuildOutputCreateTest(BuildAPITest): # Stock items have increased self.assertEqual(n_items + 1, part.stock_items.count()) + class BuildOutputScrapTest(BuildAPITest): - """Unit tests for scrapping build outputs""" + """Unit tests for scrapping build outputs.""" def scrap(self, build_id, data, expected_code=None): - """Helper method to POST to the scrap API""" + """Helper method to POST to the scrap API.""" url = reverse('api-build-output-scrap', kwargs={'pk': build_id}) response = self.post(url, data, expected_code=expected_code) @@ -1365,7 +1143,7 @@ class BuildOutputScrapTest(BuildAPITest): return response.data def test_invalid_scraps(self): - """Test that invalid scrap attempts are rejected""" + """Test that invalid scrap attempts are rejected.""" # Test with missing required fields response = self.scrap(1, {}, expected_code=400) @@ -1373,30 +1151,15 @@ class BuildOutputScrapTest(BuildAPITest): self.assertIn('This field is required', str(response[field])) # Scrap with no outputs specified - response = self.scrap( - 1, - { - 'outputs': [], - 'location': 1, - 'notes': 'Should fail', - } - ) + response = self.scrap(1, {'outputs': [], 'location': 1, 'notes': 'Should fail'}) self.assertIn('A list of build outputs must be provided', str(response)) # Scrap with an invalid output ID response = self.scrap( 1, - { - 'outputs': [ - { - 'output': 9999, - } - ], - 'location': 1, - 'notes': 'Should fail', - }, - expected_code=400 + {'outputs': [{'output': 9999}], 'location': 1, 'notes': 'Should fail'}, + expected_code=400, ) self.assertIn('object does not exist', str(response['outputs'])) @@ -1413,22 +1176,16 @@ class BuildOutputScrapTest(BuildAPITest): response = self.scrap( 1, - { - 'outputs': [ - { - 'output': output.pk, - }, - ], - 'location': 1, - 'notes': 'Should fail', - }, - expected_code=400 + {'outputs': [{'output': output.pk}], 'location': 1, 'notes': 'Should fail'}, + expected_code=400, ) - self.assertIn("Build output does not match the parent build", str(response['outputs'])) + self.assertIn( + 'Build output does not match the parent build', str(response['outputs']) + ) def test_valid_scraps(self): - """Test that valid scrap attempts succeed""" + """Test that valid scrap attempts succeed.""" # Create a build output build = Build.objects.get(pk=1) @@ -1449,23 +1206,14 @@ class BuildOutputScrapTest(BuildAPITest): 1, { 'outputs': [ - { - 'output': outputs[0].pk, - 'quantity': outputs[0].quantity, - }, - { - 'output': outputs[1].pk, - 'quantity': outputs[1].quantity, - }, - { - 'output': outputs[2].pk, - 'quantity': outputs[2].quantity, - }, + {'output': outputs[0].pk, 'quantity': outputs[0].quantity}, + {'output': outputs[1].pk, 'quantity': outputs[1].quantity}, + {'output': outputs[2].pk, 'quantity': outputs[2].quantity}, ], 'location': 1, 'notes': 'Should succeed', }, - expected_code=201 + expected_code=201, ) # There should still be three outputs associated with this build @@ -1482,7 +1230,6 @@ class BuildLineTests(BuildAPITest): def test_filter_available(self): """Filter BuildLine objects by 'available' status.""" - url = reverse('api-build-line-list') # First *all* BuildLine objects diff --git a/src/backend/InvenTree/build/test_build.py b/src/backend/InvenTree/build/test_build.py index dcc0c37f37..20f3b25403 100644 --- a/src/backend/InvenTree/build/test_build.py +++ b/src/backend/InvenTree/build/test_build.py @@ -1,38 +1,34 @@ -"""Unit tests for the 'build' models""" +"""Unit tests for the 'build' models.""" + +import logging import uuid from datetime import datetime, timedelta -from django.test import TestCase - -from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.core.exceptions import ValidationError from django.db.models import Sum +from django.test import TestCase from django.test.utils import override_settings -from InvenTree import status_codes as status -from InvenTree.unit_test import findOffloadedEvent - -import common.models -from common.settings import set_global_setting import build.tasks +import common.models from build.models import Build, BuildItem, BuildLine, generate_next_build_reference from build.status_codes import BuildStatus -from part.models import Part, BomItem, BomItemSubstitute, PartTestTemplate +from common.settings import set_global_setting +from InvenTree import status_codes as status +from InvenTree.unit_test import findOffloadedEvent +from part.models import BomItem, BomItemSubstitute, Part, PartTestTemplate from stock.models import StockItem, StockItemTestResult from users.models import Owner -import logging logger = logging.getLogger('inventree') class BuildTestBase(TestCase): """Run some tests to ensure that the Build model is working properly.""" - fixtures = [ - 'users', - ] + fixtures = ['users'] @classmethod def setUpTestData(cls): @@ -54,8 +50,8 @@ class BuildTestBase(TestCase): # Create a base "Part" cls.assembly = Part.objects.create( - name="An assembled part", - description="Why does it matter what my description is?", + name='An assembled part', + description='Why does it matter what my description is?', assembly=True, trackable=True, testable=True, @@ -63,8 +59,8 @@ class BuildTestBase(TestCase): # create one build with one required test template cls.tested_part_with_required_test = Part.objects.create( - name="Part having required tests", - description="Why does it matter what my description is?", + name='Part having required tests', + description='Why does it matter what my description is?', assembly=True, trackable=True, testable=True, @@ -72,18 +68,18 @@ class BuildTestBase(TestCase): cls.test_template_required = PartTestTemplate.objects.create( part=cls.tested_part_with_required_test, - test_name="Required test", - description="Required test template description", + test_name='Required test', + description='Required test template description', required=True, requires_value=False, - requires_attachment=False + requires_attachment=False, ) ref = generate_next_build_reference() cls.build_w_tests_trackable = Build.objects.create( reference=ref, - title="This is a build", + title='This is a build', part=cls.tested_part_with_required_test, quantity=1, issued_by=get_user_model().objects.get(pk=1), @@ -94,13 +90,13 @@ class BuildTestBase(TestCase): quantity=1, is_building=True, serial=uuid.uuid4(), - build=cls.build_w_tests_trackable + build=cls.build_w_tests_trackable, ) # now create a part with a non-required test template cls.tested_part_wo_required_test = Part.objects.create( - name="Part with one non.required test", - description="Why does it matter what my description is?", + name='Part with one non.required test', + description='Why does it matter what my description is?', assembly=True, trackable=True, testable=True, @@ -108,18 +104,18 @@ class BuildTestBase(TestCase): cls.test_template_non_required = PartTestTemplate.objects.create( part=cls.tested_part_wo_required_test, - test_name="Required test template", - description="Required test template description", + test_name='Required test template', + description='Required test template description', required=False, requires_value=False, - requires_attachment=False + requires_attachment=False, ) ref = generate_next_build_reference() cls.build_wo_tests_trackable = Build.objects.create( reference=ref, - title="This is a build", + title='This is a build', part=cls.tested_part_wo_required_test, quantity=1, issued_by=get_user_model().objects.get(pk=1), @@ -130,47 +126,33 @@ class BuildTestBase(TestCase): quantity=1, is_building=True, serial=uuid.uuid4(), - build=cls.build_wo_tests_trackable + build=cls.build_wo_tests_trackable, ) cls.sub_part_1 = Part.objects.create( - name="Widget A", - description="A widget", - component=True + name='Widget A', description='A widget', component=True ) cls.sub_part_2 = Part.objects.create( - name="Widget B", - description="A widget", - component=True + name='Widget B', description='A widget', component=True ) cls.sub_part_3 = Part.objects.create( - name="Widget C", - description="A widget", - component=True, - trackable=True + name='Widget C', description='A widget', component=True, trackable=True ) # Create BOM item links for the parts cls.bom_item_1 = BomItem.objects.create( - part=cls.assembly, - sub_part=cls.sub_part_1, - quantity=5 + part=cls.assembly, sub_part=cls.sub_part_1, quantity=5 ) cls.bom_item_2 = BomItem.objects.create( - part=cls.assembly, - sub_part=cls.sub_part_2, - quantity=3, - optional=True + part=cls.assembly, sub_part=cls.sub_part_2, quantity=3, optional=True ) # sub_part_3 is trackable! cls.bom_item_3 = BomItem.objects.create( - part=cls.assembly, - sub_part=cls.sub_part_3, - quantity=2 + part=cls.assembly, sub_part=cls.sub_part_3, quantity=2 ) ref = generate_next_build_reference() @@ -178,7 +160,7 @@ class BuildTestBase(TestCase): # Create a "Build" object to make 10x objects cls.build = Build.objects.create( reference=ref, - title="This is a build", + title='This is a build', part=cls.assembly, quantity=10, issued_by=get_user_model().objects.get(pk=1), @@ -192,17 +174,11 @@ class BuildTestBase(TestCase): # Create some build output (StockItem) objects cls.output_1 = StockItem.objects.create( - part=cls.assembly, - quantity=3, - is_building=True, - build=cls.build + part=cls.assembly, quantity=3, is_building=True, build=cls.build ) cls.output_2 = StockItem.objects.create( - part=cls.assembly, - quantity=7, - is_building=True, - build=cls.build, + part=cls.assembly, quantity=7, is_building=True, build=cls.build ) # Create some stock items to assign to the build @@ -219,12 +195,14 @@ class BuildTestBase(TestCase): class BuildTest(BuildTestBase): - """Unit testing class for the Build model""" + """Unit testing class for the Build model.""" def test_ref_int(self): - """Test the "integer reference" field used for natural sorting""" + """Test the "integer reference" field used for natural sorting.""" # Set build reference to new value - set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref}-???', change_user=None) + set_global_setting( + 'BUILDORDER_REFERENCE_PATTERN', 'BO-{ref}-???', change_user=None + ) refs = { 'BO-123-456': 123, @@ -236,10 +214,7 @@ class BuildTest(BuildTestBase): for ref, ref_int in refs.items(): build = Build( - reference=ref, - quantity=1, - part=self.assembly, - title='Making some parts', + reference=ref, quantity=1, part=self.assembly, title='Making some parts' ) self.assertEqual(build.reference_int, 0) @@ -247,18 +222,17 @@ class BuildTest(BuildTestBase): self.assertEqual(build.reference_int, ref_int) # Set build reference back to default value - set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None) + set_global_setting( + 'BUILDORDER_REFERENCE_PATTERN', + 'BO-{ref:04d}', # noqa: RUF027 + change_user=None, + ) def test_ref_validation(self): - """Test that the reference field validation works as expected""" + """Test that the reference field validation works as expected.""" # Default reference pattern = 'BO-{ref:04d} # These patterns should fail - for ref in [ - 'BO-1234x', - 'BO1234', - 'OB-1234', - 'BO--1234' - ]: + for ref in ['BO-1234x', 'BO1234', 'OB-1234', 'BO--1234']: with self.assertRaises(ValidationError): Build.objects.create( part=self.assembly, @@ -267,63 +241,53 @@ class BuildTest(BuildTestBase): title='Invalid reference', ) - for ref in [ - 'BO-1234', - 'BO-9999', - 'BO-123' - ]: + for ref in ['BO-1234', 'BO-9999', 'BO-123']: Build.objects.create( - part=self.assembly, - quantity=10, - reference=ref, - title='Valid reference', + part=self.assembly, quantity=10, reference=ref, title='Valid reference' ) # Try a new validator pattern - set_global_setting('BUILDORDER_REFERENCE_PATTERN', '{ref}-BO', change_user=None) + set_global_setting('BUILDORDER_REFERENCE_PATTERN', '{ref}-BO', change_user=None) # noqa: RUF027 - for ref in [ - '1234-BO', - '9999-BO' - ]: + for ref in ['1234-BO', '9999-BO']: Build.objects.create( - part=self.assembly, - quantity=10, - reference=ref, - title='Valid reference', + part=self.assembly, quantity=10, reference=ref, title='Valid reference' ) # Set build reference back to default value - set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None) + set_global_setting( + 'BUILDORDER_REFERENCE_PATTERN', + 'BO-{ref:04d}', # noqa: RUF027 + change_user=None, + ) def test_next_ref(self): - """Test that the next reference is automatically generated""" - set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'XYZ-{ref:06d}', change_user=None) + """Test that the next reference is automatically generated.""" + set_global_setting( + 'BUILDORDER_REFERENCE_PATTERN', 'XYZ-{ref:06d}', change_user=None + ) build = Build.objects.create( - part=self.assembly, - quantity=5, - reference='XYZ-987', - title='Some thing', + part=self.assembly, quantity=5, reference='XYZ-987', title='Some thing' ) self.assertEqual(build.reference_int, 987) # Now create one *without* specifying the reference build = Build.objects.create( - part=self.assembly, - quantity=1, - title='Some new title', + part=self.assembly, quantity=1, title='Some new title' ) self.assertEqual(build.reference, 'XYZ-000988') self.assertEqual(build.reference_int, 988) # Set build reference back to default value - set_global_setting('BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None) + set_global_setting( + 'BUILDORDER_REFERENCE_PATTERN', 'BO-{ref:04d}', change_user=None + ) def test_init(self): - """Perform some basic tests before we start the ball rolling""" + """Perform some basic tests before we start the ball rolling.""" self.assertEqual(StockItem.objects.count(), 12) # Build is PENDING @@ -348,7 +312,7 @@ class BuildTest(BuildTestBase): self.assertFalse(self.build.is_complete) def test_build_item_clean(self): - """Ensure that dodgy BuildItem objects cannot be created""" + """Ensure that dodgy BuildItem objects cannot be created.""" stock = StockItem.objects.create(part=self.assembly, quantity=99) # Create a BuiltItem which points to an invalid StockItem @@ -358,7 +322,9 @@ class BuildTest(BuildTestBase): b.save() # Create a BuildItem which has too much stock assigned - b = BuildItem(stock_item=self.stock_1_1, build_line=self.line_1, quantity=9999999) + b = BuildItem( + stock_item=self.stock_1_1, build_line=self.line_1, quantity=9999999 + ) with self.assertRaises(ValidationError): b.clean() @@ -370,19 +336,22 @@ class BuildTest(BuildTestBase): b.clean() # Ok, what about we make one that does *not* fail? - b = BuildItem(stock_item=self.stock_1_2, build_line=self.line_1, install_into=self.output_1, quantity=10) + b = BuildItem( + stock_item=self.stock_1_2, + build_line=self.line_1, + install_into=self.output_1, + quantity=10, + ) b.save() def test_duplicate_bom_line(self): - """Try to add a duplicate BOM item - it should be allowed""" + """Try to add a duplicate BOM item - it should be allowed.""" BomItem.objects.create( - part=self.assembly, - sub_part=self.sub_part_1, - quantity=99 + part=self.assembly, sub_part=self.sub_part_1, quantity=99 ) def allocate_stock(self, output, allocations): - """Allocate stock to this build, against a particular output + """Allocate stock to this build, against a particular output. Args: output: StockItem object (or None) @@ -391,52 +360,36 @@ class BuildTest(BuildTestBase): items_to_create = [] for item, quantity in allocations.items(): - # Find an appropriate BuildLine to allocate against line = BuildLine.objects.filter( - build=self.build, - bom_item__sub_part=item.part + build=self.build, bom_item__sub_part=item.part ).first() - items_to_create.append(BuildItem( - build_line=line, - stock_item=item, - quantity=quantity, - install_into=output - )) + items_to_create.append( + BuildItem( + build_line=line, + stock_item=item, + quantity=quantity, + install_into=output, + ) + ) BuildItem.objects.bulk_create(items_to_create) def test_partial_allocation(self): - """Test partial allocation of stock""" + """Test partial allocation of stock.""" # Fully allocate tracked stock against build output 1 - self.allocate_stock( - self.output_1, - { - self.stock_3_1: 6, - } - ) + self.allocate_stock(self.output_1, {self.stock_3_1: 6}) self.assertTrue(self.build.is_output_fully_allocated(self.output_1)) # Partially allocate tracked stock against build output 2 - self.allocate_stock( - self.output_2, - { - self.stock_3_1: 1, - } - ) + self.allocate_stock(self.output_2, {self.stock_3_1: 1}) self.assertFalse(self.build.is_output_fully_allocated(self.output_2)) # Partially allocate untracked stock against build - self.allocate_stock( - None, - { - self.stock_1_1: 1, - self.stock_2_1: 1 - } - ) + self.allocate_stock(None, {self.stock_1_1: 1, self.stock_2_1: 1}) self.assertFalse(self.build.is_output_fully_allocated(None)) @@ -445,12 +398,7 @@ class BuildTest(BuildTestBase): self.assertEqual(len(unallocated), 3) - self.allocate_stock( - None, - { - self.stock_1_2: 100, - } - ) + self.allocate_stock(None, {self.stock_1_2: 100}) self.assertFalse(self.build.is_fully_allocated(None)) @@ -470,44 +418,21 @@ class BuildTest(BuildTestBase): self.stock_2_1.save() # Now we "fully" allocate the untracked untracked items - self.allocate_stock( - None, - { - self.stock_1_2: 50, - self.stock_2_1: 50, - } - ) + self.allocate_stock(None, {self.stock_1_2: 50, self.stock_2_1: 50}) self.assertTrue(self.build.is_fully_allocated(tracked=False)) def test_overallocation_and_trim(self): - """Test overallocation of stock and trim function""" - + """Test overallocation of stock and trim function.""" self.assertEqual(self.build.status, status.BuildStatus.PENDING) self.build.issue_build() self.assertEqual(self.build.status, status.BuildStatus.PRODUCTION) # Fully allocate tracked stock (not eligible for trimming) - self.allocate_stock( - self.output_1, - { - self.stock_3_1: 6, - } - ) - self.allocate_stock( - self.output_2, - { - self.stock_3_1: 14, - } - ) + self.allocate_stock(self.output_1, {self.stock_3_1: 6}) + self.allocate_stock(self.output_2, {self.stock_3_1: 14}) # Fully allocate part 1 (should be left alone) - self.allocate_stock( - None, - { - self.stock_1_1: 3, - self.stock_1_2: 47, - } - ) + self.allocate_stock(None, {self.stock_1_1: 3, self.stock_1_2: 47}) extra_2_1 = StockItem.objects.create(part=self.sub_part_2, quantity=6) extra_2_2 = StockItem.objects.create(part=self.sub_part_2, quantity=4) @@ -521,9 +446,9 @@ class BuildTest(BuildTestBase): self.stock_2_3: 5, self.stock_2_4: 5, self.stock_2_5: 5, # 25 - extra_2_1: 6, # 31 - extra_2_2: 4, # 35 - } + extra_2_1: 6, # 31 + extra_2_2: 4, # 35 + }, ) self.assertTrue(self.build.is_overallocated()) @@ -550,19 +475,28 @@ class BuildTest(BuildTestBase): self.assertEqual(items.aggregate(Sum('quantity'))['quantity__sum'], 35) # However, the "available" stock quantity has been decreased - self.assertEqual(items.filter(consumed_by=None).aggregate(Sum('quantity'))['quantity__sum'], 5) + self.assertEqual( + items.filter(consumed_by=None).aggregate(Sum('quantity'))['quantity__sum'], + 5, + ) # And the "consumed_by" quantity has been increased - self.assertEqual(items.filter(consumed_by=self.build).aggregate(Sum('quantity'))['quantity__sum'], 30) + self.assertEqual( + items.filter(consumed_by=self.build).aggregate(Sum('quantity'))[ + 'quantity__sum' + ], + 30, + ) self.assertEqual(StockItem.objects.get(pk=self.stock_3_1.pk).quantity, 980) # Check that the "consumed_by" item count has increased - self.assertEqual(StockItem.objects.filter(consumed_by=self.build).count(), n + 8) + self.assertEqual( + StockItem.objects.filter(consumed_by=self.build).count(), n + 8 + ) def test_change_part(self): - """Try to change target part after creating a build""" - + """Try to change target part after creating a build.""" bo = Build.objects.create( reference='BO-9999', title='Some new build', @@ -572,9 +506,7 @@ class BuildTest(BuildTestBase): ) assembly_2 = Part.objects.create( - name="Another assembly", - description="A different assembly", - assembly=True, + name='Another assembly', description='A different assembly', assembly=True ) # Should not be able to change the part after the Build is saved @@ -583,7 +515,7 @@ class BuildTest(BuildTestBase): bo.clean() def test_cancel(self): - """Test cancellation of the build""" + """Test cancellation of the build.""" # TODO """ self.allocate_stock(50, 50, 200, self.output_1) @@ -591,10 +523,9 @@ class BuildTest(BuildTestBase): self.assertEqual(BuildItem.objects.count(), 0) """ - pass def test_complete(self): - """Test completion of a build output""" + """Test completion of a build output.""" self.stock_1_1.quantity = 1000 self.stock_1_1.save() @@ -609,25 +540,15 @@ class BuildTest(BuildTestBase): { self.stock_1_1: self.stock_1_1.quantity, # Allocate *all* stock from this item self.stock_1_2: 10, - self.stock_2_1: 30 - } + self.stock_2_1: 30, + }, ) # Allocate tracked parts to output_1 - self.allocate_stock( - self.output_1, - { - self.stock_3_1: 6 - } - ) + self.allocate_stock(self.output_1, {self.stock_3_1: 6}) # Allocate tracked parts to output_2 - self.allocate_stock( - self.output_2, - { - self.stock_3_1: 14 - } - ) + self.allocate_stock(self.output_2, {self.stock_3_1: 14}) self.assertTrue(self.build.is_fully_allocated(None)) self.assertTrue(self.build.is_fully_allocated(self.output_1)) @@ -665,25 +586,32 @@ class BuildTest(BuildTestBase): self.assertFalse(output.is_building) def test_complete_with_required_tests(self): - """Test the prevention completion when a required test is missing feature""" - + """Test the prevention completion when a required test is missing feature.""" # with required tests incompleted the save should fail - set_global_setting('PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS', True, change_user=None) + set_global_setting( + 'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS', True, change_user=None + ) with self.assertRaises(ValidationError): - self.build_w_tests_trackable.complete_build_output(self.stockitem_with_required_test, None) + self.build_w_tests_trackable.complete_build_output( + self.stockitem_with_required_test, None + ) # let's complete the required test and see if it could be saved StockItemTestResult.objects.create( stock_item=self.stockitem_with_required_test, template=self.test_template_required, - result=True + result=True, ) - self.build_w_tests_trackable.complete_build_output(self.stockitem_with_required_test, None) + self.build_w_tests_trackable.complete_build_output( + self.stockitem_with_required_test, None + ) # let's see if a non required test could be saved - self.build_wo_tests_trackable.complete_build_output(self.stockitem_wo_required_test, None) + self.build_wo_tests_trackable.complete_build_output( + self.stockitem_wo_required_test, None + ) def test_overdue_notification(self): """Test sending of notifications when a build order is overdue.""" @@ -694,26 +622,25 @@ class BuildTest(BuildTestBase): build.tasks.check_overdue_build_orders() message = common.models.NotificationMessage.objects.get( - category='build.overdue_build_order', - user__id=1, + category='build.overdue_build_order', user__id=1 ) self.assertEqual(message.name, 'Overdue Build Order') def test_new_build_notification(self): - """Test that a notification is sent when a new build is created""" + """Test that a notification is sent when a new build is created.""" Build.objects.create( reference='BO-9999', title='Some new build', part=self.assembly, quantity=5, issued_by=get_user_model().objects.get(pk=2), - responsible=Owner.create(obj=Group.objects.get(pk=3)) + responsible=Owner.create(obj=Group.objects.get(pk=3)), ) # Two notifications should have been sent messages = common.models.NotificationMessage.objects.filter( - category='build.new_build', + category='build.new_build' ) self.assertEqual(messages.count(), 1) @@ -728,12 +655,12 @@ class BuildTest(BuildTestBase): @override_settings( TESTING_TABLE_EVENTS=True, PLUGIN_TESTING_EVENTS=True, - PLUGIN_TESTING_EVENTS_ASYNC=True + PLUGIN_TESTING_EVENTS_ASYNC=True, ) def test_events(self): """Test that build events are triggered correctly.""" - from django_q.models import OrmQ + from build.events import BuildEvents set_global_setting('ENABLE_PLUGINS_EVENTS', True) @@ -747,7 +674,7 @@ class BuildTest(BuildTestBase): part=self.assembly, quantity=5, issued_by=get_user_model().objects.get(pk=2), - responsible=Owner.create(obj=Group.objects.get(pk=3)) + responsible=Owner.create(obj=Group.objects.get(pk=3)), ) # Check that the 'build.created' event was triggered @@ -769,9 +696,7 @@ class BuildTest(BuildTestBase): # Check that the 'build.issued' event was triggered task = findOffloadedEvent( - BuildEvents.ISSUED, - matching_kwargs=['id'], - clear_after=True, + BuildEvents.ISSUED, matching_kwargs=['id'], clear_after=True ) self.assertIsNotNone(task) @@ -781,7 +706,12 @@ class BuildTest(BuildTestBase): def test_metadata(self): """Unit tests for the metadata field.""" # Make sure a BuildItem exists before trying to run this test - b = BuildItem(stock_item=self.stock_1_2, build_line=self.line_1, install_into=self.output_1, quantity=10) + b = BuildItem( + stock_item=self.stock_1_2, + build_line=self.line_1, + install_into=self.output_1, + quantity=10, + ) b.save() for model in [Build, BuildItem]: @@ -802,28 +732,20 @@ class BuildTest(BuildTestBase): class AutoAllocationTests(BuildTestBase): - """Tests for auto allocating stock against a build order""" + """Tests for auto allocating stock against a build order.""" def setUp(self): - """Init routines for this unit test class""" + """Init routines for this unit test class.""" super().setUp() # Add a "substitute" part for bom_item_2 alt_part = Part.objects.create( - name="alt part", - description="An alternative part!", - component=True, + name='alt part', description='An alternative part!', component=True ) - BomItemSubstitute.objects.create( - bom_item=self.bom_item_2, - part=alt_part, - ) + BomItemSubstitute.objects.create(bom_item=self.bom_item_2, part=alt_part) - StockItem.objects.create( - part=alt_part, - quantity=500, - ) + StockItem.objects.create(part=alt_part, quantity=500) def test_auto_allocate(self): """Run the 'auto-allocate' function. What do we expect to happen? @@ -840,10 +762,7 @@ class AutoAllocationTests(BuildTestBase): self.assertFalse(self.build.is_fully_allocated(tracked=False)) # Stock is not interchangeable, nothing will happen - self.build.auto_allocate_stock( - interchangeable=False, - substitutes=False, - ) + self.build.auto_allocate_stock(interchangeable=False, substitutes=False) self.assertFalse(self.build.is_fully_allocated(tracked=False)) @@ -857,9 +776,7 @@ class AutoAllocationTests(BuildTestBase): # This time we expect stock to be allocated! self.build.auto_allocate_stock( - interchangeable=True, - substitutes=False, - optional_items=True, + interchangeable=True, substitutes=False, optional_items=True ) self.assertFalse(self.build.is_fully_allocated(tracked=False)) @@ -873,10 +790,7 @@ class AutoAllocationTests(BuildTestBase): self.assertEqual(self.line_2.unallocated_quantity(), 5) # This time, allow substitute parts to be used! - self.build.auto_allocate_stock( - interchangeable=True, - substitutes=True, - ) + self.build.auto_allocate_stock(interchangeable=True, substitutes=True) self.assertEqual(self.line_1.unallocated_quantity(), 0) self.assertEqual(self.line_2.unallocated_quantity(), 5) @@ -885,11 +799,9 @@ class AutoAllocationTests(BuildTestBase): self.assertFalse(self.line_2.is_fully_allocated()) def test_fully_auto(self): - """We should be able to auto-allocate against a build in a single go""" + """We should be able to auto-allocate against a build in a single go.""" self.build.auto_allocate_stock( - interchangeable=True, - substitutes=True, - optional_items=True, + interchangeable=True, substitutes=True, optional_items=True ) self.assertTrue(self.build.is_fully_allocated(tracked=False)) diff --git a/src/backend/InvenTree/build/test_migrations.py b/src/backend/InvenTree/build/test_migrations.py index 4a0c720e5d..924b45d249 100644 --- a/src/backend/InvenTree/build/test_migrations.py +++ b/src/backend/InvenTree/build/test_migrations.py @@ -16,21 +16,17 @@ class TestForwardMigrations(MigratorTestCase): Part = self.old_state.apps.get_model('part', 'part') buildable_part = Part.objects.create( - name='Widget', - description='Buildable Part', - active=True, + name='Widget', description='Buildable Part', active=True ) Build = self.old_state.apps.get_model('build', 'build') Build.objects.create( - part=buildable_part, - title='A build of some stuff', - quantity=50, + part=buildable_part, title='A build of some stuff', quantity=50 ) def test_items_exist(self): - """Test to ensure that the 'assembly' field is correctly configured""" + """Test to ensure that the 'assembly' field is correctly configured.""" Part = self.new_state.apps.get_model('part', 'part') self.assertEqual(Part.objects.count(), 1) @@ -57,30 +53,15 @@ class TestReferenceMigration(MigratorTestCase): """Create some builds.""" Part = self.old_state.apps.get_model('part', 'part') - part = Part.objects.create( - name='Part', - description='A test part', - ) + part = Part.objects.create(name='Part', description='A test part') Build = self.old_state.apps.get_model('build', 'build') - Build.objects.create( - part=part, - title='My very first build', - quantity=10 - ) + Build.objects.create(part=part, title='My very first build', quantity=10) - Build.objects.create( - part=part, - title='My very second build', - quantity=10 - ) + Build.objects.create(part=part, title='My very second build', quantity=10) - Build.objects.create( - part=part, - title='My very third build', - quantity=10 - ) + Build.objects.create(part=part, title='My very third build', quantity=10) # Ensure that the builds *do not* have a 'reference' field for build in Build.objects.all(): @@ -88,7 +69,7 @@ class TestReferenceMigration(MigratorTestCase): print(build.reference) def test_build_reference(self): - """Test that the build reference is correctly assigned to the PK of the Build""" + """Test that the build reference is correctly assigned to the PK of the Build.""" Build = self.new_state.apps.get_model('build', 'build') self.assertEqual(Build.objects.count(), 3) @@ -108,21 +89,16 @@ class TestReferencePatternMigration(MigratorTestCase): migrate_to = ('build', unit_test.getNewestMigrationFile('build')) def prepare(self): - """Create some initial data prior to migration""" + """Create some initial data prior to migration.""" Setting = self.old_state.apps.get_model('common', 'inventreesetting') # Create a custom existing prefix so we can confirm the operation is working - Setting.objects.create( - key='BUILDORDER_REFERENCE_PREFIX', - value='BuildOrder-', - ) + Setting.objects.create(key='BUILDORDER_REFERENCE_PREFIX', value='BuildOrder-') Part = self.old_state.apps.get_model('part', 'part') assembly = Part.objects.create( - name='Assy 1', - description='An assembly', - level=0, lft=0, rght=0, tree_id=0, + name='Assy 1', description='An assembly', level=0, lft=0, rght=0, tree_id=0 ) Build = self.old_state.apps.get_model('build', 'build') @@ -130,14 +106,17 @@ class TestReferencePatternMigration(MigratorTestCase): for idx in range(1, 11): Build.objects.create( part=assembly, - title=f"Build {idx}", + title=f'Build {idx}', quantity=idx, - reference=f"{idx + 100}", - level=0, lft=0, rght=0, tree_id=0, + reference=f'{idx + 100}', + level=0, + lft=0, + rght=0, + tree_id=0, ) def test_reference_migration(self): - """Test that the reference fields have been correctly updated""" + """Test that the reference fields have been correctly updated.""" Build = self.new_state.apps.get_model('build', 'build') for build in Build.objects.all(): @@ -165,7 +144,7 @@ class TestBuildLineCreation(MigratorTestCase): migrate_to = ('build', '0047_auto_20230606_1058') def prepare(self): - """Create data to work with""" + """Create data to work with.""" # Model references Part = self.old_state.apps.get_model('part', 'part') BomItem = self.old_state.apps.get_model('part', 'bomitem') @@ -182,40 +161,44 @@ class TestBuildLineCreation(MigratorTestCase): name='Assembly', description='An assembly', assembly=True, - level=0, lft=0, rght=0, tree_id=0, + level=0, + lft=0, + rght=0, + tree_id=0, ) # Create components for idx in range(1, 11): part = Part.objects.create( - name=f"Part {idx}", - description=f"Part {idx}", - level=0, lft=0, rght=0, tree_id=0, + name=f'Part {idx}', + description=f'Part {idx}', + level=0, + lft=0, + rght=0, + tree_id=0, ) # Create plentiful stock StockItem.objects.create( - part=part, - quantity=1000, - level=0, lft=0, rght=0, tree_id=0, + part=part, quantity=1000, level=0, lft=0, rght=0, tree_id=0 ) # Create a BOM item BomItem.objects.create( - part=assembly, - sub_part=part, - quantity=idx, - reference=f"REF-{idx}", + part=assembly, sub_part=part, quantity=idx, reference=f'REF-{idx}' ) # Create some builds for idx in range(1, 4): build = Build.objects.create( part=assembly, - title=f"Build {idx}", + title=f'Build {idx}', quantity=idx * 10, - reference=f"REF-{idx}", - level=0, lft=0, rght=0, tree_id=0, + reference=f'REF-{idx}', + level=0, + lft=0, + rght=0, + tree_id=0, ) # Allocate stock to the build @@ -229,7 +212,7 @@ class TestBuildLineCreation(MigratorTestCase): ) def test_build_line_creation(self): - """Test that the BuildLine objects have been created correctly""" + """Test that the BuildLine objects have been created correctly.""" Build = self.new_state.apps.get_model('build', 'build') BomItem = self.new_state.apps.get_model('part', 'bomitem') BuildLine = self.new_state.apps.get_model('build', 'buildline') @@ -254,10 +237,7 @@ class TestBuildLineCreation(MigratorTestCase): # Check that each BuildItem has been linked to a BuildLine for item in BuildItem.objects.all(): self.assertIsNotNone(item.build_line) - self.assertEqual( - item.stock_item.part, - item.build_line.bom_item.sub_part, - ) + self.assertEqual(item.stock_item.part, item.build_line.bom_item.sub_part) item = BuildItem.objects.first() @@ -273,12 +253,8 @@ class TestBuildLineCreation(MigratorTestCase): for line in BuildLine.objects.all(): # Check that the quantity is correct self.assertEqual( - line.quantity, - line.build.quantity * line.bom_item.quantity, + line.quantity, line.build.quantity * line.bom_item.quantity ) # Check that the linked parts are correct - self.assertEqual( - line.build.part, - line.bom_item.part, - ) + self.assertEqual(line.build.part, line.bom_item.part) diff --git a/src/backend/InvenTree/build/tests.py b/src/backend/InvenTree/build/tests.py index cb8c84086f..fd560a6fad 100644 --- a/src/backend/InvenTree/build/tests.py +++ b/src/backend/InvenTree/build/tests.py @@ -1,39 +1,26 @@ -"""Basic unit tests for the BuildOrder app""" - -from django.core.exceptions import ValidationError -from django.test import tag -from django.urls import reverse +"""Basic unit tests for the BuildOrder app.""" from datetime import datetime, timedelta +from django.core.exceptions import ValidationError + +from build.status_codes import BuildStatus +from common.settings import set_global_setting from InvenTree.unit_test import InvenTreeTestCase +from part.models import BomItem, Part from .models import Build -from part.models import Part, BomItem -from stock.models import StockItem - -from common.settings import set_global_setting -from build.status_codes import BuildStatus class BuildTestSimple(InvenTreeTestCase): - """Basic set of tests for the BuildOrder model functionality""" + """Basic set of tests for the BuildOrder model functionality.""" - fixtures = [ - 'category', - 'part', - 'location', - 'build', - ] + fixtures = ['category', 'part', 'location', 'build'] - roles = [ - 'build.change', - 'build.add', - 'build.delete', - ] + roles = ['build.change', 'build.add', 'build.delete'] def test_build_objects(self): - """Ensure the Build objects were correctly created""" + """Ensure the Build objects were correctly created.""" self.assertEqual(Build.objects.count(), 5) b = Build.objects.get(pk=2) self.assertEqual(b.batch, 'B2') @@ -42,12 +29,12 @@ class BuildTestSimple(InvenTreeTestCase): self.assertEqual(str(b), 'BO-0002') def test_url(self): - """Test URL lookup""" + """Test URL lookup.""" b1 = Build.objects.get(pk=1) self.assertEqual(b1.get_absolute_url(), '/platform/manufacturing/build-order/1') def test_is_complete(self): - """Test build completion status""" + """Test build completion status.""" b1 = Build.objects.get(pk=1) b2 = Build.objects.get(pk=2) @@ -72,7 +59,7 @@ class BuildTestSimple(InvenTreeTestCase): self.assertFalse(build.is_overdue) def test_is_active(self): - """Test active / inactive build status""" + """Test active / inactive build status.""" b1 = Build.objects.get(pk=1) b2 = Build.objects.get(pk=2) @@ -91,7 +78,6 @@ class BuildTestSimple(InvenTreeTestCase): def test_build_create(self): """Test creation of build orders via API.""" - n = Build.objects.count() # Find an assembly part @@ -105,13 +91,9 @@ class BuildTestSimple(InvenTreeTestCase): # Let's create some BOM items for this assembly for component in Part.objects.filter(assembly=False, component=True)[:15]: - try: BomItem.objects.create( - part=assembly, - sub_part=component, - reference='xxx', - quantity=5 + part=assembly, sub_part=component, reference='xxx', quantity=5 ) except ValidationError: pass diff --git a/src/backend/InvenTree/build/validators.py b/src/backend/InvenTree/build/validators.py index 87ebfc25ba..a4213c8750 100644 --- a/src/backend/InvenTree/build/validators.py +++ b/src/backend/InvenTree/build/validators.py @@ -1,15 +1,15 @@ -"""Validation methods for the build app""" +"""Validation methods for the build app.""" def generate_next_build_reference(): - """Generate the next available BuildOrder reference""" + """Generate the next available BuildOrder reference.""" from build.models import Build return Build.generate_reference() def validate_build_order_reference_pattern(pattern): - """Validate the BuildOrder reference 'pattern' setting""" + """Validate the BuildOrder reference 'pattern' setting.""" from build.models import Build Build.validate_reference_pattern(pattern)