2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-05-28 11:59:23 +00:00

Offload build output functions:

- cancel output
- scrap output
- complete output

Perform these in the background worker, and monitor for progress on the frontend.
This commit is contained in:
Oliver Walters
2026-05-23 05:14:52 +00:00
parent 27ca0836e7
commit 7cefb3c6df
6 changed files with 240 additions and 86 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ def isAppLoaded(app_name: str) -> bool:
def isInTestMode(): def isInTestMode():
"""Returns True if the database is in testing mode.""" """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(): def isWaitingForDatabase():
+89 -4
View File
@@ -735,14 +735,81 @@ class BuildOutputScrap(BuildOrderContextMixin, CreateAPI):
ctx['to_complete'] = False ctx['to_complete'] = False
return ctx 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): class BuildOutputComplete(BuildOrderContextMixin, CreateAPI):
"""API endpoint for completing build outputs.""" """API endpoint for completing build outputs."""
queryset = Build.objects.none() queryset = Build.objects.none()
serializer_class = build.serializers.BuildOutputCompleteSerializer 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): class BuildOutputDelete(BuildOrderContextMixin, CreateAPI):
"""API endpoint for deleting multiple build outputs.""" """API endpoint for deleting multiple build outputs."""
@@ -750,15 +817,33 @@ class BuildOutputDelete(BuildOrderContextMixin, CreateAPI):
def get_serializer_context(self): def get_serializer_context(self):
"""Add extra context information to the endpoint serializer.""" """Add extra context information to the endpoint serializer."""
ctx = super().get_serializer_context() ctx = super().get_serializer_context()
ctx['to_complete'] = False ctx['to_complete'] = False
return ctx return ctx
queryset = Build.objects.none() queryset = Build.objects.none()
serializer_class = build.serializers.BuildOutputDeleteSerializer 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): class BuildFinish(BuildOrderContextMixin, CreateAPI):
"""API endpoint for marking a build as finished (completed).""" """API endpoint for marking a build as finished (completed)."""
@@ -462,18 +462,6 @@ class BuildOutputDeleteSerializer(serializers.Serializer):
return data 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): class BuildOutputScrapSerializer(serializers.Serializer):
"""Scrapping one or more build outputs.""" """Scrapping one or more build outputs."""
@@ -518,27 +506,6 @@ class BuildOutputScrapSerializer(serializers.Serializer):
return data 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): class BuildOutputCompleteSerializer(serializers.Serializer):
"""DRF serializer for completing one or more build outputs.""" """DRF serializer for completing one or more build outputs."""
@@ -610,42 +577,6 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
return data 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): class BuildIssueSerializer(serializers.Serializer):
"""DRF serializer for issuing a build order.""" """DRF serializer for issuing a build order."""
+107
View File
@@ -100,6 +100,113 @@ def complete_build_allocations(build_id: int, user_id: int):
build_order.complete_allocations(user) 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') @tracer.start_as_current_span('update_build_order_lines')
def update_build_order_lines(bom_item_pk: int): def update_build_order_lines(bom_item_pk: int):
"""Update all BuildOrderLineItem objects which reference a particular BomItem. """Update all BuildOrderLineItem objects which reference a particular BomItem.
+3 -3
View File
@@ -385,7 +385,7 @@ export function useCompleteBuildOutputsForm({
title: t`Complete Build Outputs`, title: t`Complete Build Outputs`,
fields: buildOutputCompleteFields, fields: buildOutputCompleteFields,
onFormSuccess: onFormSuccess, onFormSuccess: onFormSuccess,
successMessage: t`Build outputs have been completed`, successMessage: null,
size: '80%' size: '80%'
}); });
} }
@@ -466,7 +466,7 @@ export function useScrapBuildOutputsForm({
), ),
fields: buildOutputScrapFields, fields: buildOutputScrapFields,
onFormSuccess: onFormSuccess, onFormSuccess: onFormSuccess,
successMessage: t`Build outputs have been scrapped`, successMessage: null,
size: '80%' size: '80%'
}); });
} }
@@ -527,7 +527,7 @@ export function useCancelBuildOutputsForm({
), ),
fields: buildOutputCancelFields, fields: buildOutputCancelFields,
onFormSuccess: onFormSuccess, onFormSuccess: onFormSuccess,
successMessage: t`Build outputs have been cancelled`, successMessage: null,
size: '80%' size: '80%'
}); });
} }
@@ -346,31 +346,62 @@ export default function BuildOutputTable({
const [selectedOutputs, setSelectedOutputs] = useState<any[]>([]); const [selectedOutputs, setSelectedOutputs] = useState<any[]>([]);
const [completeTaskId, setCompleteTaskId] = useState<string>('');
const [scrapTaskId, setScrapTaskId] = useState<string>('');
const [deleteTaskId, setDeleteTaskId] = useState<string>('');
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({ const completeBuildOutputsForm = useCompleteBuildOutputsForm({
build: build, build: build,
outputs: selectedOutputs, outputs: selectedOutputs,
hasTrackedItems: hasTrackedItems, hasTrackedItems: hasTrackedItems,
onFormSuccess: () => { onFormSuccess: (response: any) => {
table.refreshTable(true); setCompleteTaskId(response.task_id);
refreshBuild();
} }
}); });
const scrapBuildOutputsForm = useScrapBuildOutputsForm({ const scrapBuildOutputsForm = useScrapBuildOutputsForm({
build: build, build: build,
outputs: selectedOutputs, outputs: selectedOutputs,
onFormSuccess: () => { onFormSuccess: (response: any) => {
table.refreshTable(true); setScrapTaskId(response.task_id);
refreshBuild();
} }
}); });
const cancelBuildOutputsForm = useCancelBuildOutputsForm({ const cancelBuildOutputsForm = useCancelBuildOutputsForm({
build: build, build: build,
outputs: selectedOutputs, outputs: selectedOutputs,
onFormSuccess: () => { onFormSuccess: (response: any) => {
table.refreshTable(true); setDeleteTaskId(response.task_id);
refreshBuild();
} }
}); });