Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/backend/InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
"""InvenTree API version information."""

# InvenTree API version
INVENTREE_API_VERSION = 464
INVENTREE_API_VERSION = 465
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""

INVENTREE_API_TEXT = """

v465 -> 2026-03-18 : https://github.com/inventree/InvenTree/pull/11529/
- BuildOrderAutoAllocate endpoint now returns a task ID which can be used to track the progress of the auto-allocation process
- BuildOrderConsume endpoint now returns a task ID which can be used to track the progress of the stock consumption process

v464 -> 2026-03-15 : https://github.com/inventree/InvenTree/pull/11527
- Add API endpoint for monitoring the progress of a particular background task

Expand Down
77 changes: 74 additions & 3 deletions src/backend/InvenTree/build/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from django_filters.rest_framework.filterset import FilterSet
from drf_spectacular.utils import extend_schema, extend_schema_field
from rest_framework import serializers, status
from rest_framework.exceptions import ValidationError
from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.response import Response

import build.models as build_models
Expand Down Expand Up @@ -662,6 +662,13 @@ def get_source_build(self) -> Build | None:
class BuildOrderContextMixin:
"""Mixin class which adds build order as serializer context variable."""

def get_build(self):
"""Return the Build object associated with this API endpoint."""
try:
return Build.objects.get(pk=self.kwargs.get('pk', None))
except (ValueError, Build.DoesNotExist):
raise NotFound(_('Build not found'))

def get_serializer_context(self):
"""Add extra context information to the endpoint serializer."""
ctx = super().get_serializer_context()
Expand All @@ -670,8 +677,8 @@ def get_serializer_context(self):
ctx['to_complete'] = True

try:
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
except Exception:
ctx['build'] = self.get_build()
except NotFound:
pass

return ctx
Expand Down Expand Up @@ -764,6 +771,37 @@ class BuildAutoAllocate(BuildOrderContextMixin, CreateAPI):
queryset = Build.objects.none()
serializer_class = build.serializers.BuildAutoAllocationSerializer

@extend_schema(responses={200: common.serializers.TaskDetailSerializer})
def post(self, *args, **kwargs):
"""Override the POST method to handle auto allocation task.

As this is offloaded to the background task,
we return information about the background task which is performing the auto allocation operation.
"""
from build.tasks import auto_allocate_build
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

# Offload the task to the background worker
task_id = offload_task(
auto_allocate_build,
build.pk,
location=data.get('location', None),
exclude_location=data.get('exclude_location', None),
interchangeable=data['interchangeable'],
substitutes=data['substitutes'],
optional_items=data['optional_items'],
item_type=data.get('item_type', 'untracked'),
group='build',
)

response = common.serializers.TaskDetailSerializer.from_task(task_id).data
return Response(response, status=response['http_status'])


class BuildAllocate(BuildOrderContextMixin, CreateAPI):
"""API endpoint to allocate stock items to a build order.
Expand All @@ -786,6 +824,39 @@ class BuildConsume(BuildOrderContextMixin, CreateAPI):
queryset = Build.objects.none()
serializer_class = build.serializers.BuildConsumeSerializer

@extend_schema(responses={200: common.serializers.TaskDetailSerializer})
def post(self, *args, **kwargs):
"""Override the POST method to handle consume task.

As this is offloaded to the background task,
we return information about the background task which is performing the consume operation.
"""
from build.tasks import consume_build_stock
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

# Extract the information we need to consume build stock
items = data.get('items', [])
lines = data.get('lines', [])
notes = data.get('notes', '')

# Offload the task to the background worker
task_id = offload_task(
consume_build_stock,
build.pk,
lines=[line['build_line'].pk for line in lines],
items={item['build_item'].pk: item['quantity'] for item in items},
user_id=self.request.user.pk,
notes=notes,
)

response = common.serializers.TaskDetailSerializer.from_task(task_id).data
return Response(response, status=response['http_status'])


class BuildIssue(BuildOrderContextMixin, CreateAPI):
"""API endpoint for issuing a BuildOrder."""
Expand Down
67 changes: 0 additions & 67 deletions src/backend/InvenTree/build/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
from rest_framework import serializers
from rest_framework.serializers import ValidationError

import build.tasks
import common.filters
import common.settings
import company.serializers
Expand All @@ -38,7 +37,6 @@
NotesFieldMixin,
enable_filter,
)
from InvenTree.tasks import offload_task
from stock.generators import generate_batch_code
from stock.models import StockItem, StockLocation
from stock.serializers import (
Expand All @@ -51,7 +49,6 @@

from .models import Build, BuildItem, BuildLine
from .status_codes import BuildStatus
from .tasks import consume_build_item, consume_build_line


class BuildSerializer(
Expand Down Expand Up @@ -1129,27 +1126,6 @@ class Meta:
help_text=_('Select item type to auto-allocate'),
)

def save(self):
"""Perform the auto-allocation step."""
import InvenTree.tasks

data = self.validated_data

build_order = self.context['build']

if not InvenTree.tasks.offload_task(
build.tasks.auto_allocate_build,
build_order.pk,
location=data.get('location', None),
exclude_location=data.get('exclude_location', None),
interchangeable=data['interchangeable'],
substitutes=data['substitutes'],
optional_items=data['optional_items'],
item_type=data.get('item_type', 'untracked'),
group='build',
):
raise ValidationError(_('Failed to start auto-allocation task'))


class BuildItemSerializer(
FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer
Expand Down Expand Up @@ -1847,46 +1823,3 @@ def validate(self, data):
raise ValidationError(_('At least one item or line must be provided'))

return data

@transaction.atomic
def save(self):
"""Perform the stock consumption step."""
data = self.validated_data
request = self.context.get('request')
notes = data.get('notes', '')

# We may be passed either a list of BuildItem or BuildLine instances
items = data.get('items', [])
lines = data.get('lines', [])

with transaction.atomic():
# Process the provided BuildItem objects
for item in items:
build_item = item['build_item']
quantity = item['quantity']

if build_item.install_into:
# If the build item is tracked into an output, we do not consume now
# Instead, it gets consumed when the output is completed
continue

# Offload a background task to consume this BuildItem
offload_task(
consume_build_item,
build_item.pk,
quantity,
notes=notes,
user_id=request.user.pk if request else None,
)

# Process the provided BuildLine objects
for line in lines:
build_line = line['build_line']

# Offload a background task to consume this BuildLine
offload_task(
consume_build_line,
build_line.pk,
notes=notes,
user_id=request.user.pk if request else None,
)
94 changes: 44 additions & 50 deletions src/backend/InvenTree/build/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

from datetime import timedelta
from decimal import Decimal
from typing import Optional

from django.contrib.auth.models import User
from django.db import transaction
from django.utils.translation import gettext_lazy as _

import structlog
Expand All @@ -27,61 +29,53 @@ def auto_allocate_build(build_id: int, **kwargs):
"""Run auto-allocation for a specified BuildOrder."""
from build.models import Build

build_order = 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,
)
return

build_order = Build.objects.get(pk=build_id)
build_order.auto_allocate_stock(**kwargs)


@tracer.start_as_current_span('consume_build_item')
def consume_build_item(
item_id: str, quantity, notes: str = '', user_id: int | None = None
@tracer.start_as_current_span('consume_build_stock')
def consume_build_stock(
build_id: int,
lines: Optional[list[int]] = None,
items: Optional[dict] = None,
user_id: int | None = None,
**kwargs,
):
"""Consume stock against a particular BuildOrderLineItem allocation."""
from build.models import BuildItem

item = BuildItem.objects.filter(pk=item_id).first()

if not item:
logger.warning(
'Could not consume stock for BuildItem <%s> - BuildItem does not exist',
item_id,
)
return

item.complete_allocation(
quantity=quantity,
notes=notes,
user=User.objects.filter(pk=user_id).first() if user_id else None,
)


@tracer.start_as_current_span('consume_build_line')
def consume_build_line(line_id: int, notes: str = '', user_id: int | None = None):
"""Consume stock against a particular BuildOrderLineItem."""
from build.models import BuildLine

line_item = BuildLine.objects.filter(pk=line_id).first()
"""Consume stock for the specified BuildOrder.

if not line_item:
logger.warning(
'Could not consume stock for LineItem <%s> - LineItem does not exist',
line_id,
)
return

for item in line_item.allocations.all():
item.complete_allocation(
quantity=item.quantity,
notes=notes,
user=User.objects.filter(pk=user_id).first() if user_id else None,
)
Arguments:
build_id: The ID of the BuildOrder to consume stock for
lines: Optional list of BuildLine IDs to consume
items: Optional dict of BuildItem IDs (and quantities)to consume
user_id: The ID of the user who initiated the stock consumption
"""
from build.models import Build, BuildItem, BuildLine

build = Build.objects.get(pk=build_id)
user = User.objects.filter(pk=user_id).first() if user_id else None

lines = lines or []
items = items or {}
notes = kwargs.pop('notes', '')

# Extract the relevant BuildLine and BuildItem objects
with transaction.atomic():
# Consume each of the specified BuildLine objects
for line_id in lines:
if build_line := BuildLine.objects.filter(pk=line_id, build=build).first():
for item in build_line.allocations.all():
item.complete_allocation(
quantity=item.quantity, notes=notes, user=user
)

# Consume each of the specified BuildItem objects
for item_id, quantity in items.items():
if build_item := BuildItem.objects.filter(
pk=item_id, build_line__build=build
).first():
build_item.complete_allocation(
quantity=quantity, notes=notes, user=user
)


@tracer.start_as_current_span('complete_build_allocations')
Expand Down
8 changes: 4 additions & 4 deletions src/backend/InvenTree/build/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -970,12 +970,12 @@ def test_auto_allocate_tracked(self):
url = reverse('api-build-auto-allocate', kwargs={'pk': build.pk})

# Allocate only 'untracked' items - this should not allocate our tracked item
self.post(url, data={'item_type': 'untracked'})
self.post(url, data={'item_type': 'untracked'}, expected_code=200)

self.assertEqual(N, BuildItem.objects.count())

# Allocate 'tracked' items - this should allocate our tracked item
self.post(url, data={'item_type': 'tracked'})
self.post(url, data={'item_type': 'tracked'}, expected_code=200)

# A new BuildItem should have been created
self.assertEqual(N + 1, BuildItem.objects.count())
Expand Down Expand Up @@ -1735,7 +1735,7 @@ def test_consume_lines(self):
'lines': [{'build_line': line.pk} for line in self.build.build_lines.all()]
}

self.post(url, data, expected_code=201)
self.post(url, data, expected_code=200)

self.assertEqual(self.build.allocated_stock.count(), 0)
self.assertEqual(self.build.consumed_stock.count(), 3)
Expand All @@ -1758,7 +1758,7 @@ def test_consume_items(self):
]
}

self.post(url, data, expected_code=201)
self.post(url, data, expected_code=200)

self.assertEqual(self.build.allocated_stock.count(), 0)
self.assertEqual(self.build.consumed_stock.count(), 3)
Expand Down
4 changes: 2 additions & 2 deletions src/frontend/src/forms/BuildForms.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -853,7 +853,7 @@ export function useConsumeBuildItemsForm({
url: ApiEndpoints.build_order_consume,
pk: buildId,
title: t`Consume Stock`,
successMessage: t`Stock items scheduled to be consumed`,
successMessage: null,
onFormSuccess: onFormSuccess,
size: '80%',
fields: consumeFields,
Expand Down Expand Up @@ -954,7 +954,7 @@ export function useConsumeBuildLinesForm({
url: ApiEndpoints.build_order_consume,
pk: buildId,
title: t`Consume Stock`,
successMessage: t`Stock items scheduled to be consumed`,
successMessage: null,
onFormSuccess: onFormSuccess,
fields: consumeFields,
initialData: {
Expand Down
Loading
Loading