From 7cefb3c6df338356bfd7c6337e5376544c1fe163 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 23 May 2026 05:14:52 +0000 Subject: [PATCH] Offload build output functions: - cancel output - scrap output - complete output Perform these in the background worker, and monitor for progress on the frontend. --- src/backend/InvenTree/InvenTree/ready.py | 2 +- src/backend/InvenTree/build/api.py | 93 ++++++++++++++- src/backend/InvenTree/build/serializers.py | 69 ----------- src/backend/InvenTree/build/tasks.py | 107 ++++++++++++++++++ src/frontend/src/forms/BuildForms.tsx | 6 +- .../src/tables/build/BuildOutputTable.tsx | 49 ++++++-- 6 files changed, 240 insertions(+), 86 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/ready.py b/src/backend/InvenTree/InvenTree/ready.py index 745d77cfda..35c1130b15 100644 --- a/src/backend/InvenTree/InvenTree/ready.py +++ b/src/backend/InvenTree/InvenTree/ready.py @@ -37,7 +37,7 @@ def isAppLoaded(app_name: str) -> bool: def isInTestMode(): """Returns True if the database is in testing mode.""" - return 'test' in sys.argv or sys.argv[0].endswith('pytest') + return any(x in sys.argv for x in ['test', 'pytest']) def isWaitingForDatabase(): diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index 7253f87f95..a20f59c363 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -735,14 +735,81 @@ class BuildOutputScrap(BuildOrderContextMixin, CreateAPI): ctx['to_complete'] = False return ctx + @extend_schema(responses={200: common.serializers.TaskDetailSerializer}) + def post(self, *args, **kwargs): + """Override POST to offload scrapping to the background worker.""" + from build.tasks import scrap_build_outputs + from InvenTree.tasks import offload_task + + build = self.get_build() + serializer = self.get_serializer(data=self.request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + + task_id = offload_task( + scrap_build_outputs, + build.pk, + outputs=[ + { + 'output_id': item['output'].pk, + 'quantity': float(item['quantity']) + if item.get('quantity') is not None + else None, + } + for item in data['outputs'] + ], + location_id=data['location'].pk, + notes=data.get('notes', ''), + discard_allocations=data.get('discard_allocations', False), + user_id=self.request.user.pk, + group='build', + ) + + response = common.serializers.TaskDetailSerializer.from_task(task_id).data + return Response(response, status=response['http_status']) + class BuildOutputComplete(BuildOrderContextMixin, CreateAPI): """API endpoint for completing build outputs.""" queryset = Build.objects.none() - serializer_class = build.serializers.BuildOutputCompleteSerializer + @extend_schema(responses={200: common.serializers.TaskDetailSerializer}) + def post(self, *args, **kwargs): + """Override POST to offload build output completion to the background worker.""" + from build.tasks import complete_build_outputs + from InvenTree.tasks import offload_task + + build = self.get_build() + serializer = self.get_serializer(data=self.request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + + location = data.get('location') + + task_id = offload_task( + complete_build_outputs, + build.pk, + outputs=[ + { + 'output_id': item['output'].pk, + 'quantity': float(item['quantity']) + if item.get('quantity') is not None + else None, + } + for item in data['outputs'] + ], + location_id=location.pk if location else None, + status=data.get('status_custom_key'), + notes=data.get('notes', ''), + user_id=self.request.user.pk, + group='build', + ) + + response = common.serializers.TaskDetailSerializer.from_task(task_id).data + return Response(response, status=response['http_status']) + class BuildOutputDelete(BuildOrderContextMixin, CreateAPI): """API endpoint for deleting multiple build outputs.""" @@ -750,15 +817,33 @@ class BuildOutputDelete(BuildOrderContextMixin, CreateAPI): def get_serializer_context(self): """Add extra context information to the endpoint serializer.""" ctx = super().get_serializer_context() - ctx['to_complete'] = False - return ctx queryset = Build.objects.none() - serializer_class = build.serializers.BuildOutputDeleteSerializer + @extend_schema(responses={200: common.serializers.TaskDetailSerializer}) + def post(self, *args, **kwargs): + """Override POST to offload build output deletion to the background worker.""" + from build.tasks import delete_build_outputs + from InvenTree.tasks import offload_task + + build = self.get_build() + serializer = self.get_serializer(data=self.request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + + task_id = offload_task( + delete_build_outputs, + build.pk, + output_ids=[item['output'].pk for item in data['outputs']], + group='build', + ) + + response = common.serializers.TaskDetailSerializer.from_task(task_id).data + return Response(response, status=response['http_status']) + class BuildFinish(BuildOrderContextMixin, CreateAPI): """API endpoint for marking a build as finished (completed).""" diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 9264af7375..d7cc0e5729 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -462,18 +462,6 @@ class BuildOutputDeleteSerializer(serializers.Serializer): return data - def save(self): - """'save' the serializer to delete the build outputs.""" - data = self.validated_data - outputs = data.get('outputs', []) - - build = self.context['build'] - - with transaction.atomic(): - for item in outputs: - output = item['output'] - build.delete_output(output) - class BuildOutputScrapSerializer(serializers.Serializer): """Scrapping one or more build outputs.""" @@ -518,27 +506,6 @@ class BuildOutputScrapSerializer(serializers.Serializer): return data - def save(self): - """Save the serializer to scrap the build outputs.""" - build = self.context['build'] - request = self.context.get('request') - data = self.validated_data - outputs = data.get('outputs', []) - - # Scrap the build outputs - with transaction.atomic(): - for item in outputs: - output = item['output'] - quantity = item.get('quantity', None) - build.scrap_build_output( - output, - quantity, - data.get('location', None), - user=request.user if request else None, - notes=data.get('notes', ''), - discard_allocations=data.get('discard_allocations', False), - ) - class BuildOutputCompleteSerializer(serializers.Serializer): """DRF serializer for completing one or more build outputs.""" @@ -610,42 +577,6 @@ class BuildOutputCompleteSerializer(serializers.Serializer): return data - def save(self): - """Save the serializer to complete the build outputs.""" - build = self.context['build'] - request = self.context.get('request') - - data = self.validated_data - - location = data.get('location', None) - status = data.get('status_custom_key', StockStatus.OK.value) - notes = data.get('notes', '') - - outputs = data.get('outputs', []) - - # 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() - ) - - # Mark the specified build outputs as "complete" - with transaction.atomic(): - for item in outputs: - output = item['output'] - quantity = item.get('quantity', None) - - build.complete_build_output( - output, - request.user if request else None, - quantity=quantity, - location=location, - status=status, - notes=notes, - required_tests=required_tests, - prevent_on_incomplete=prevent_on_incomplete, - ) - class BuildIssueSerializer(serializers.Serializer): """DRF serializer for issuing a build order.""" diff --git a/src/backend/InvenTree/build/tasks.py b/src/backend/InvenTree/build/tasks.py index 363dac3e19..a2f690048a 100644 --- a/src/backend/InvenTree/build/tasks.py +++ b/src/backend/InvenTree/build/tasks.py @@ -100,6 +100,113 @@ def complete_build_allocations(build_id: int, user_id: int): build_order.complete_allocations(user) +@tracer.start_as_current_span('delete_build_outputs') +def delete_build_outputs(build_id: int, output_ids: list, **kwargs): + """Delete (cancel) specified build outputs for a BuildOrder. + + Arguments: + build_id: The ID of the BuildOrder + output_ids: List of StockItem PKs to delete + """ + from build.models import Build + from stock.models import StockItem + + build = Build.objects.get(pk=build_id) + + with transaction.atomic(): + for output_id in output_ids: + output = StockItem.objects.filter(pk=output_id).first() + if output: + build.delete_output(output) + + +@tracer.start_as_current_span('scrap_build_outputs') +def scrap_build_outputs( + build_id: int, + outputs: list, + location_id: int, + notes: str = '', + discard_allocations: bool = False, + user_id: int | None = None, + **kwargs, +): + """Scrap specified build outputs for a BuildOrder. + + Arguments: + build_id: The ID of the BuildOrder + outputs: List of dicts with 'output_id' and 'quantity' + location_id: PK of the destination StockLocation + notes: Reason for scrapping + discard_allocations: If True, discard (not consume) allocations + user_id: PK of the user initiating the action + """ + from build.models import Build + from stock.models import StockItem, StockLocation + + build = Build.objects.get(pk=build_id) + location = StockLocation.objects.get(pk=location_id) + user = User.objects.filter(pk=user_id).first() if user_id else None + + with transaction.atomic(): + for item in outputs: + output = StockItem.objects.filter(pk=item['output_id']).first() + if output: + build.scrap_build_output( + output, + item.get('quantity'), + location, + user=user, + notes=notes, + discard_allocations=discard_allocations, + ) + + +@tracer.start_as_current_span('complete_build_outputs') +def complete_build_outputs( + build_id: int, + outputs: list, + location_id: int | None, + status: int, + notes: str = '', + user_id: int | None = None, + **kwargs, +): + """Complete specified build outputs for a BuildOrder. + + Arguments: + build_id: The ID of the BuildOrder + outputs: List of dicts with 'output_id' and optional 'quantity' + location_id: PK of the destination StockLocation (or None) + status: Stock status code to assign to completed outputs + notes: Completion notes + user_id: PK of the user initiating the action + """ + from build.models import Build + from stock.models import StockItem, StockLocation + + build = Build.objects.get(pk=build_id) + location = ( + StockLocation.objects.filter(pk=location_id).first() if location_id else None + ) + user = User.objects.filter(pk=user_id).first() if user_id else None + + required_tests = build.part.getRequiredTests() + + with transaction.atomic(): + for item in outputs: + output = StockItem.objects.filter(pk=item['output_id']).first() + if output: + build.complete_build_output( + output, + user, + quantity=item.get('quantity'), + location=location, + status=status, + notes=notes, + required_tests=required_tests, + ) + + @tracer.start_as_current_span('update_build_order_lines') def update_build_order_lines(bom_item_pk: int): """Update all BuildOrderLineItem objects which reference a particular BomItem. diff --git a/src/frontend/src/forms/BuildForms.tsx b/src/frontend/src/forms/BuildForms.tsx index 0707f433db..6f1ea7d961 100644 --- a/src/frontend/src/forms/BuildForms.tsx +++ b/src/frontend/src/forms/BuildForms.tsx @@ -385,7 +385,7 @@ export function useCompleteBuildOutputsForm({ title: t`Complete Build Outputs`, fields: buildOutputCompleteFields, onFormSuccess: onFormSuccess, - successMessage: t`Build outputs have been completed`, + successMessage: null, size: '80%' }); } @@ -466,7 +466,7 @@ export function useScrapBuildOutputsForm({ ), fields: buildOutputScrapFields, onFormSuccess: onFormSuccess, - successMessage: t`Build outputs have been scrapped`, + successMessage: null, size: '80%' }); } @@ -527,7 +527,7 @@ export function useCancelBuildOutputsForm({ ), fields: buildOutputCancelFields, onFormSuccess: onFormSuccess, - successMessage: t`Build outputs have been cancelled`, + successMessage: null, size: '80%' }); } diff --git a/src/frontend/src/tables/build/BuildOutputTable.tsx b/src/frontend/src/tables/build/BuildOutputTable.tsx index 2ecdcb2f76..a0676aa8af 100644 --- a/src/frontend/src/tables/build/BuildOutputTable.tsx +++ b/src/frontend/src/tables/build/BuildOutputTable.tsx @@ -346,31 +346,62 @@ export default function BuildOutputTable({ const [selectedOutputs, setSelectedOutputs] = useState([]); + const [completeTaskId, setCompleteTaskId] = useState(''); + const [scrapTaskId, setScrapTaskId] = useState(''); + const [deleteTaskId, setDeleteTaskId] = useState(''); + + useBackgroundTask({ + taskId: completeTaskId, + message: t`Completing build outputs`, + successMessage: t`Build outputs have been completed`, + onSuccess: () => { + table.refreshTable(true); + refreshBuild(); + } + }); + + useBackgroundTask({ + taskId: scrapTaskId, + message: t`Scrapping build outputs`, + successMessage: t`Build outputs have been scrapped`, + onSuccess: () => { + table.refreshTable(true); + refreshBuild(); + } + }); + + useBackgroundTask({ + taskId: deleteTaskId, + message: t`Cancelling build outputs`, + successMessage: t`Build outputs have been cancelled`, + onSuccess: () => { + table.refreshTable(true); + refreshBuild(); + } + }); + const completeBuildOutputsForm = useCompleteBuildOutputsForm({ build: build, outputs: selectedOutputs, hasTrackedItems: hasTrackedItems, - onFormSuccess: () => { - table.refreshTable(true); - refreshBuild(); + onFormSuccess: (response: any) => { + setCompleteTaskId(response.task_id); } }); const scrapBuildOutputsForm = useScrapBuildOutputsForm({ build: build, outputs: selectedOutputs, - onFormSuccess: () => { - table.refreshTable(true); - refreshBuild(); + onFormSuccess: (response: any) => { + setScrapTaskId(response.task_id); } }); const cancelBuildOutputsForm = useCancelBuildOutputsForm({ build: build, outputs: selectedOutputs, - onFormSuccess: () => { - table.refreshTable(true); - refreshBuild(); + onFormSuccess: (response: any) => { + setDeleteTaskId(response.task_id); } });