mirror of
https://github.com/inventree/InvenTree.git
synced 2026-09-02 10:18:42 +00:00
Merge commit '749c4715ee022bdb39b500f5c3776c1ee18411e3' into block-notes
This commit is contained in:
@@ -1,12 +1,15 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 493
|
||||
INVENTREE_API_VERSION = 494
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v493 -> 2026-05-18 : https://github.com/inventree/InvenTree/pull/11961
|
||||
v494 -> 2026-05-23 : https://github.com/inventree/InvenTree/pull/11990
|
||||
- Offload build output operations to a background task, and return a task ID which can be used to monitor the progress of the task
|
||||
|
||||
v493 -> 2026-05-22 : https://github.com/inventree/InvenTree/pull/11961
|
||||
- Adds "thumbnail" field to the Attachment API endpoint, which provides a URL to a thumbnail image for image attachments (if available)
|
||||
|
||||
v492 -> 2026-05-22 : https://github.com/inventree/InvenTree/pull/11281
|
||||
@@ -17,7 +20,7 @@ v491 -> 2026-05-21 : https://github.com/inventree/InvenTree/pull/11979
|
||||
- Add API serializer for deleting a stock location
|
||||
|
||||
v490 -> 2026-05-19 : https://github.com/inventree/InvenTree/pull/11963
|
||||
- moves user-self-filtered endpoints to /user/me/ to make their security boundaries clearer
|
||||
- Moves user-self-filtered endpoints to /user/me/ to make their security boundaries clearer
|
||||
|
||||
v489 -> 2026-05-18 : https://github.com/inventree/InvenTree/pull/11962
|
||||
- Removes the "remote_image" field from the Part API endpoint
|
||||
|
||||
@@ -37,7 +37,9 @@ 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']) or sys.argv[0].endswith(
|
||||
'pytest'
|
||||
)
|
||||
|
||||
|
||||
def isWaitingForDatabase():
|
||||
|
||||
@@ -178,7 +178,7 @@ def check_existing_task(taskname, group: str, *args, **kwargs) -> Optional[str]:
|
||||
|
||||
# Iterate through all available tasks, with the most recent first
|
||||
for task in OrmQ.objects.all().order_by('-id'):
|
||||
if task.func() != taskname and task.task['func'] != taskname:
|
||||
if task.func() != taskname and task.task.get('func') != taskname:
|
||||
# Task does not match
|
||||
continue
|
||||
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -37,7 +37,6 @@ from build.validators import (
|
||||
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,
|
||||
@@ -652,7 +651,6 @@ class Build(
|
||||
|
||||
return self.is_fully_allocated(tracked=False)
|
||||
|
||||
@transaction.atomic
|
||||
def complete_allocations(self, user) -> None:
|
||||
"""Complete all stock allocations for this build order.
|
||||
|
||||
@@ -663,7 +661,7 @@ class Build(
|
||||
|
||||
# Ensure that there are no longer any BuildItem objects
|
||||
# which point to this Build Order
|
||||
self.allocated_stock.delete()
|
||||
self.allocated_stock.all().delete()
|
||||
|
||||
@transaction.atomic
|
||||
def complete_build(self, user: User, trim_allocated_stock: bool = False):
|
||||
@@ -703,66 +701,20 @@ class Build(
|
||||
_('Cannot complete build order with incomplete outputs')
|
||||
)
|
||||
|
||||
if trim_allocated_stock:
|
||||
self.trim_allocated_stock()
|
||||
# Offload background task to complete build allocations
|
||||
InvenTree.tasks.offload_task(
|
||||
build.tasks.complete_build,
|
||||
self.pk,
|
||||
user.pk if user else None,
|
||||
trim_allocated_stock=trim_allocated_stock,
|
||||
group='build',
|
||||
)
|
||||
|
||||
self.completion_date = InvenTree.helpers.current_date()
|
||||
self.completed_by = user
|
||||
self.status = BuildStatus.COMPLETE.value
|
||||
self.save()
|
||||
|
||||
# Offload task to complete build allocations
|
||||
if not InvenTree.tasks.offload_task(
|
||||
build.tasks.complete_build_allocations,
|
||||
self.pk,
|
||||
user.pk if user else None,
|
||||
group='build',
|
||||
):
|
||||
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]
|
||||
|
||||
# Also inform anyone subscribed to the assembly part
|
||||
targets.extend(self.part.get_subscribers())
|
||||
|
||||
# Notify those users interested in the parent build
|
||||
if self.parent:
|
||||
targets.append(self.parent.issued_by)
|
||||
targets.append(self.parent.responsible)
|
||||
|
||||
# Notify users if this build points to a sales order
|
||||
if self.sales_order:
|
||||
targets.append(self.sales_order.created_by)
|
||||
targets.append(self.sales_order.responsible)
|
||||
|
||||
build = self
|
||||
name = _(f'Build order {build} has been completed')
|
||||
|
||||
context = {
|
||||
'build': 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},
|
||||
}
|
||||
|
||||
trigger_notification(
|
||||
build,
|
||||
'build.completed',
|
||||
targets=targets,
|
||||
context=context,
|
||||
target_exclude=[user],
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def issue_build(self):
|
||||
"""Mark the Build as IN PRODUCTION.
|
||||
@@ -840,26 +792,15 @@ class Build(
|
||||
remove_allocated_stock = kwargs.get('remove_allocated_stock', False)
|
||||
remove_incomplete_outputs = kwargs.get('remove_incomplete_outputs', False)
|
||||
|
||||
if remove_allocated_stock:
|
||||
# Offload task to remove allocated stock
|
||||
if not InvenTree.tasks.offload_task(
|
||||
build.tasks.complete_build_allocations,
|
||||
self.pk,
|
||||
user.pk if user else None,
|
||||
group='build',
|
||||
):
|
||||
raise ValidationError(
|
||||
_('Failed to offload task to complete build allocations')
|
||||
)
|
||||
|
||||
else:
|
||||
self.allocated_stock.all().delete()
|
||||
|
||||
# Remove incomplete outputs (if required)
|
||||
if remove_incomplete_outputs:
|
||||
outputs = self.build_outputs.filter(is_building=True)
|
||||
|
||||
outputs.delete()
|
||||
# Offload background task to take care of the expensive operations
|
||||
InvenTree.tasks.offload_task(
|
||||
build.tasks.cancel_build,
|
||||
self.pk,
|
||||
user.pk if user else None,
|
||||
remove_allocated_stock=remove_allocated_stock,
|
||||
remove_incomplete_outputs=remove_incomplete_outputs,
|
||||
group='build',
|
||||
)
|
||||
|
||||
# Date of 'completion' is the date the build was cancelled
|
||||
self.completion_date = InvenTree.helpers.current_date()
|
||||
@@ -868,17 +809,6 @@ class Build(
|
||||
self.status = BuildStatus.CANCELLED.value
|
||||
self.save()
|
||||
|
||||
# Notify users that the order has been canceled
|
||||
InvenTree.helpers_model.notify_responsible(
|
||||
self,
|
||||
Build,
|
||||
exclude=self.issued_by,
|
||||
content=InvenTreeNotificationBodies.OrderCanceled,
|
||||
extra_users=self.part.get_subscribers(),
|
||||
)
|
||||
|
||||
trigger_event(BuildEvents.CANCELLED, id=self.pk)
|
||||
|
||||
@transaction.atomic
|
||||
def deallocate_stock(self, build_line=None, output=None):
|
||||
"""Deallocate stock from this Build.
|
||||
@@ -1084,7 +1014,6 @@ class Build(
|
||||
"""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) -> None:
|
||||
"""Removes the allocated untracked items from stock."""
|
||||
# Find all BuildItem objects which point to this build
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -100,6 +100,214 @@ 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('cancel_build')
|
||||
def cancel_build(
|
||||
build_id: int,
|
||||
user_id: int,
|
||||
remove_allocated_stock: bool = False,
|
||||
remove_incomplete_outputs: bool = False,
|
||||
):
|
||||
"""Tasks to run after a BuildOrder is cancelled.
|
||||
|
||||
Arguments:
|
||||
build_id: The ID of the BuildOrder which has been cancelled
|
||||
user_id: The ID of the user who cancelled the BuildOrder
|
||||
remove_allocated_stock: If True, consume any allocated stock
|
||||
remove_incomplete_outputs: If True, delete any incomplete build outputs
|
||||
|
||||
"""
|
||||
from build.models import Build
|
||||
|
||||
build = Build.objects.get(pk=build_id)
|
||||
|
||||
if remove_allocated_stock:
|
||||
complete_build_allocations(build_id, user_id)
|
||||
else:
|
||||
build.allocated_stock.all().delete()
|
||||
|
||||
if remove_incomplete_outputs:
|
||||
build.build_outputs.filter(is_building=True).delete()
|
||||
|
||||
# Notify users that the order has been canceled
|
||||
InvenTree.helpers_model.notify_responsible(
|
||||
build,
|
||||
Build,
|
||||
exclude=build.issued_by,
|
||||
content=common.notifications.InvenTreeNotificationBodies.OrderCanceled,
|
||||
extra_users=build.part.get_subscribers(),
|
||||
)
|
||||
|
||||
trigger_event(BuildEvents.CANCELLED, id=build.pk)
|
||||
|
||||
|
||||
@tracer.start_as_current_span('complete_build')
|
||||
def complete_build(build_id: int, user_id: int, trim_allocated_stock: bool = False):
|
||||
"""Tasks to run after a BuildOrder is completed.
|
||||
|
||||
Arguments:
|
||||
build_id: The ID of the BuildOrder which has been completed
|
||||
user_id: The ID of the user who completed the BuildOrder
|
||||
trim_allocated_stock: If True, trim any allocated stock which was not consumed
|
||||
"""
|
||||
from build.models import Build
|
||||
|
||||
build = Build.objects.get(pk=build_id)
|
||||
user = User.objects.filter(pk=user_id).first() if user_id else None
|
||||
|
||||
if trim_allocated_stock:
|
||||
build.trim_allocated_stock()
|
||||
|
||||
# Complete any remaining allocations for this build order
|
||||
complete_build_allocations(build_id, user_id)
|
||||
|
||||
# Register an event
|
||||
trigger_event(BuildEvents.COMPLETED, id=build.pk)
|
||||
|
||||
# Notify users that this build has been completed
|
||||
targets = [build.issued_by, build.responsible]
|
||||
|
||||
# Also inform anyone subscribed to the assembly part
|
||||
targets.extend(build.part.get_subscribers())
|
||||
|
||||
# Notify those users interested in the parent build
|
||||
if build.parent:
|
||||
targets.append(build.parent.issued_by)
|
||||
targets.append(build.parent.responsible)
|
||||
|
||||
# Notify users if this build points to a sales order
|
||||
if build.sales_order:
|
||||
targets.append(build.sales_order.created_by)
|
||||
targets.append(build.sales_order.responsible)
|
||||
|
||||
name = _(f'Build order {build} has been completed')
|
||||
|
||||
context = {
|
||||
'build': build,
|
||||
'name': name,
|
||||
'slug': 'build.completed',
|
||||
'message': _('A build order has been completed'),
|
||||
'link': InvenTree.helpers_model.construct_absolute_url(
|
||||
build.get_absolute_url()
|
||||
),
|
||||
'template': {'html': 'email/build_order_completed.html', 'subject': name},
|
||||
}
|
||||
|
||||
common.notifications.trigger_notification(
|
||||
build,
|
||||
'build.completed',
|
||||
targets=targets,
|
||||
context=context,
|
||||
target_exclude=[user],
|
||||
)
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
@@ -155,7 +155,7 @@ class BuildTest(BuildAPITest):
|
||||
self.post(
|
||||
reverse('api-build-output-complete', kwargs={'pk': 99999}),
|
||||
{},
|
||||
expected_code=400,
|
||||
expected_code=404,
|
||||
)
|
||||
|
||||
data = self.post(self.url, {}, expected_code=400).data
|
||||
@@ -226,8 +226,8 @@ class BuildTest(BuildAPITest):
|
||||
'location': 1,
|
||||
'status': StockStatus.ATTENTION.value,
|
||||
},
|
||||
expected_code=201,
|
||||
max_query_count=400,
|
||||
expected_code=200,
|
||||
max_query_count=450,
|
||||
)
|
||||
|
||||
self.assertEqual(self.build.incomplete_outputs.count(), 0)
|
||||
@@ -446,7 +446,7 @@ class BuildTest(BuildAPITest):
|
||||
self.post(
|
||||
delete_url,
|
||||
{'outputs': [{'output': output.pk} for output in outputs[1:3]]},
|
||||
expected_code=201,
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
# Two build outputs have been removed
|
||||
@@ -473,7 +473,7 @@ class BuildTest(BuildAPITest):
|
||||
'outputs': [{'output': output.pk} for output in outputs[3:]],
|
||||
'location': 4,
|
||||
},
|
||||
expected_code=201,
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
# Check that the outputs have been completed
|
||||
@@ -1353,7 +1353,7 @@ class BuildOutputCreateTest(BuildAPITest):
|
||||
url, data={'quantity': 5, 'serial_numbers': '1,2,3-5'}, expected_code=201
|
||||
)
|
||||
|
||||
# Build outputs have incdeased
|
||||
# Build outputs have increased
|
||||
self.assertEqual(n_outputs + 5, build.output_count)
|
||||
|
||||
# Stock items have increased
|
||||
@@ -1466,7 +1466,7 @@ class BuildOutputScrapTest(BuildAPITest):
|
||||
'location': 1,
|
||||
'notes': 'Should succeed',
|
||||
},
|
||||
expected_code=201,
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
# There should still be three outputs associated with this build
|
||||
@@ -1534,7 +1534,7 @@ class BuildOutputScrapTest(BuildAPITest):
|
||||
|
||||
# Partially complete the output (with a valid quantity)
|
||||
data['outputs'][0]['quantity'] = 4
|
||||
self.post(url, data, expected_code=201)
|
||||
self.post(url, data, expected_code=200)
|
||||
|
||||
build.refresh_from_db()
|
||||
output.refresh_from_db()
|
||||
@@ -1571,13 +1571,22 @@ class BuildOutputCancelTest(BuildAPITest):
|
||||
set_global_setting('STOCK_ALLOW_DELETE_SERIALIZED', True)
|
||||
url = reverse('api-build-output-delete', kwargs={'pk': build.pk})
|
||||
|
||||
self.post(url, data={'outputs': [{'output': output_ids[0]}]}, expected_code=201)
|
||||
self.post(url, data={'outputs': [{'output': output_ids[0]}]}, expected_code=200)
|
||||
|
||||
# Prevent deletion of serialized stock items, and try again
|
||||
# Note that this should still succeed, independent of the global setting
|
||||
set_global_setting('STOCK_ALLOW_DELETE_SERIALIZED', False)
|
||||
|
||||
self.post(url, data={'outputs': [{'output': output_ids[1]}]}, expected_code=201)
|
||||
response = self.post(
|
||||
url, data={'outputs': [{'output': output_ids[1]}]}, expected_code=200
|
||||
)
|
||||
|
||||
# Response should be the task info - the cancellation is performed asynchronously
|
||||
self.assertIn('task_id', response.data)
|
||||
self.assertFalse(response.data['exists'])
|
||||
self.assertFalse(response.data['pending'])
|
||||
self.assertTrue(response.data['complete'])
|
||||
self.assertTrue(response.data['success'])
|
||||
|
||||
# The outputs should have been scrapped
|
||||
self.assertEqual(build.build_outputs.count(), N)
|
||||
|
||||
@@ -27,6 +27,7 @@ from InvenTree.unit_test import (
|
||||
from order.models import PurchaseOrder, PurchaseOrderLineItem
|
||||
from part.models import BomItem, BomItemSubstitute, Part, PartTestTemplate
|
||||
from stock.models import StockItem, StockItemTestResult, StockLocation
|
||||
from stock.status_codes import StockStatus
|
||||
from users.models import Owner
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
@@ -557,14 +558,29 @@ class BuildTest(BuildTestBase):
|
||||
bo.clean()
|
||||
|
||||
def test_cancel(self):
|
||||
"""Test cancellation of the build."""
|
||||
# TODO
|
||||
"""
|
||||
self.allocate_stock(50, 50, 200, self.output_1)
|
||||
self.build.cancel_build(None)
|
||||
"""Test build cancellation: status is updated and allocations are removed by default."""
|
||||
self.build.issue_build()
|
||||
|
||||
self.assertEqual(BuildItem.objects.count(), 0)
|
||||
"""
|
||||
self.allocate_stock(None, {self.stock_1_2: 50})
|
||||
self.assertGreater(self.build.allocated_stock.count(), 0)
|
||||
|
||||
initial_output_count = self.build.build_outputs.filter(is_building=True).count()
|
||||
self.assertGreater(initial_output_count, 0)
|
||||
|
||||
self.build.cancel_build(None)
|
||||
self.build.refresh_from_db()
|
||||
|
||||
self.assertEqual(self.build.status, BuildStatus.CANCELLED)
|
||||
|
||||
# Allocations removed (but stock not consumed) by default
|
||||
self.assertEqual(self.build.allocated_stock.count(), 0)
|
||||
self.assertIsNone(StockItem.objects.get(pk=self.stock_1_2.pk).consumed_by)
|
||||
|
||||
# Incomplete outputs preserved by default (remove_incomplete_outputs=False)
|
||||
self.assertEqual(
|
||||
self.build.build_outputs.filter(is_building=True).count(),
|
||||
initial_output_count,
|
||||
)
|
||||
|
||||
def test_complete(self):
|
||||
"""Test completion of a build output."""
|
||||
@@ -1107,3 +1123,276 @@ class ExternalBuildTest(InvenTreeAPITestCase):
|
||||
# Filter by 'not external'
|
||||
response = self.get(url, {'external': 'false'})
|
||||
self.assertEqual(len(response.data), 2)
|
||||
|
||||
|
||||
class BuildTaskTests(BuildTestBase):
|
||||
"""Direct unit tests for the background task functions in build/tasks.py.
|
||||
|
||||
These tests call task functions directly (synchronously) to verify the
|
||||
business logic they encapsulate, independently of the API and offload mechanism.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""Create a stock location available to all task tests."""
|
||||
super().setUp()
|
||||
self.location = StockLocation.objects.create(name='Task Test Location')
|
||||
|
||||
def allocate_stock(self, output, allocations):
|
||||
"""Create BuildItem allocations against self.build for the given output."""
|
||||
items_to_create = []
|
||||
for item, quantity in allocations.items():
|
||||
line = BuildLine.objects.filter(
|
||||
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,
|
||||
)
|
||||
)
|
||||
BuildItem.objects.bulk_create(items_to_create)
|
||||
|
||||
def _setup_complete_build(self):
|
||||
"""Helper: allocate stock fully and complete all outputs so the build is ready to complete."""
|
||||
self.stock_1_1.quantity = 1000
|
||||
self.stock_1_1.save()
|
||||
self.stock_2_1.quantity = 30
|
||||
self.stock_2_1.save()
|
||||
|
||||
self.build.issue_build()
|
||||
|
||||
# Allocate untracked parts
|
||||
self.allocate_stock(
|
||||
None, {self.stock_1_1: 50, self.stock_1_2: 10, self.stock_2_1: 30}
|
||||
)
|
||||
# Allocate tracked parts to each output
|
||||
self.allocate_stock(self.output_1, {self.stock_3_1: 6})
|
||||
self.allocate_stock(self.output_2, {self.stock_3_1: 14})
|
||||
|
||||
self.build.complete_build_output(self.output_1, None)
|
||||
self.build.complete_build_output(self.output_2, None)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# cancel_build task
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def test_cancel_task_discards_allocations(self):
|
||||
"""cancel_build with remove_allocated_stock=False: allocations deleted, stock not consumed."""
|
||||
self.build.issue_build()
|
||||
self.allocate_stock(None, {self.stock_1_2: 50})
|
||||
self.assertGreater(self.build.allocated_stock.count(), 0)
|
||||
|
||||
build.tasks.cancel_build(
|
||||
self.build.pk, self.user.pk, remove_allocated_stock=False
|
||||
)
|
||||
|
||||
# BuildItem rows gone
|
||||
self.assertEqual(self.build.allocated_stock.count(), 0)
|
||||
# Stock item was NOT consumed
|
||||
self.assertIsNone(StockItem.objects.get(pk=self.stock_1_2.pk).consumed_by)
|
||||
|
||||
def test_cancel_task_consumes_allocations(self):
|
||||
"""cancel_build with remove_allocated_stock=True: stock items are marked consumed."""
|
||||
self.build.issue_build()
|
||||
self.allocate_stock(None, {self.stock_1_2: 50})
|
||||
|
||||
build.tasks.cancel_build(
|
||||
self.build.pk, self.user.pk, remove_allocated_stock=True
|
||||
)
|
||||
|
||||
# All BuildItem rows gone
|
||||
self.assertEqual(self.build.allocated_stock.count(), 0)
|
||||
# The allocated (non-trackable) stock was consumed
|
||||
self.assertGreater(self.build.consumed_stock.count(), 0)
|
||||
|
||||
def test_cancel_task_removes_incomplete_outputs(self):
|
||||
"""cancel_build with remove_incomplete_outputs=True: in-progress outputs are deleted."""
|
||||
self.build.issue_build()
|
||||
initial_count = self.build.build_outputs.filter(is_building=True).count()
|
||||
self.assertGreater(initial_count, 0)
|
||||
|
||||
build.tasks.cancel_build(
|
||||
self.build.pk, self.user.pk, remove_incomplete_outputs=True
|
||||
)
|
||||
|
||||
self.assertEqual(self.build.build_outputs.filter(is_building=True).count(), 0)
|
||||
|
||||
def test_cancel_task_preserves_incomplete_outputs(self):
|
||||
"""cancel_build with remove_incomplete_outputs=False: in-progress outputs are kept."""
|
||||
self.build.issue_build()
|
||||
initial_count = self.build.build_outputs.filter(is_building=True).count()
|
||||
self.assertGreater(initial_count, 0)
|
||||
|
||||
build.tasks.cancel_build(
|
||||
self.build.pk, self.user.pk, remove_incomplete_outputs=False
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.build.build_outputs.filter(is_building=True).count(), initial_count
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# complete_build task
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
@override_settings(
|
||||
TESTING_TABLE_EVENTS=True,
|
||||
PLUGIN_TESTING_EVENTS=True,
|
||||
PLUGIN_TESTING_EVENTS_ASYNC=True,
|
||||
)
|
||||
def test_complete_build_task_triggers_event(self):
|
||||
"""complete_build task fires the BuildEvents.COMPLETED event."""
|
||||
from django_q.models import OrmQ
|
||||
|
||||
from build.events import BuildEvents
|
||||
|
||||
set_global_setting('ENABLE_PLUGINS_EVENTS', True)
|
||||
OrmQ.objects.all().delete()
|
||||
|
||||
self._setup_complete_build()
|
||||
self.build.complete_build(self.user)
|
||||
|
||||
task = findOffloadedEvent(BuildEvents.COMPLETED, matching_kwargs=['id'])
|
||||
self.assertIsNotNone(task)
|
||||
self.assertEqual(task.kwargs()['id'], self.build.pk)
|
||||
|
||||
set_global_setting('ENABLE_PLUGINS_EVENTS', False)
|
||||
|
||||
def test_complete_build_task_trim_stock(self):
|
||||
"""complete_build with trim_allocated_stock=True removes over-allocations before consuming."""
|
||||
self.stock_1_2.quantity = 100
|
||||
self.stock_1_2.save()
|
||||
self.stock_2_1.quantity = 30
|
||||
self.stock_2_1.save()
|
||||
|
||||
self.build.issue_build()
|
||||
|
||||
# Over-allocate sub_part_1: need 50, allocate 100
|
||||
self.allocate_stock(None, {self.stock_1_2: 100, self.stock_2_1: 30})
|
||||
self.allocate_stock(self.output_1, {self.stock_3_1: 6})
|
||||
self.allocate_stock(self.output_2, {self.stock_3_1: 14})
|
||||
|
||||
self.assertTrue(self.build.is_overallocated())
|
||||
|
||||
self.build.complete_build_output(self.output_1, None)
|
||||
self.build.complete_build_output(self.output_2, None)
|
||||
self.assertTrue(self.build.can_complete)
|
||||
|
||||
self.build.complete_build(self.user, trim_allocated_stock=True)
|
||||
self.build.refresh_from_db()
|
||||
self.assertEqual(self.build.status, BuildStatus.COMPLETE)
|
||||
|
||||
# Only 50 units of sub_part_1 should have been consumed (not 100)
|
||||
consumed_qty = StockItem.objects.filter(
|
||||
consumed_by=self.build, part=self.sub_part_1
|
||||
).aggregate(total=Sum('quantity'))['total']
|
||||
self.assertEqual(consumed_qty, 50)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# delete_build_outputs task
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def test_delete_build_outputs_skips_missing_id(self):
|
||||
"""delete_build_outputs silently skips nonexistent output IDs and deletes valid ones."""
|
||||
from build.tasks import delete_build_outputs
|
||||
|
||||
# Create an output directly to avoid serial-number requirements on the trackable assembly
|
||||
output = StockItem.objects.create(
|
||||
part=self.assembly, quantity=3, is_building=True, build=self.build
|
||||
)
|
||||
real_id = output.pk
|
||||
|
||||
# Mix a valid ID with a nonexistent one — must not raise
|
||||
delete_build_outputs(self.build.pk, [real_id, 99999])
|
||||
|
||||
self.assertFalse(StockItem.objects.filter(pk=real_id).exists())
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# scrap_build_outputs task
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def test_scrap_build_outputs_discard_allocations(self):
|
||||
"""scrap_build_outputs with discard_allocations=True removes allocations without consuming stock."""
|
||||
from build.tasks import scrap_build_outputs
|
||||
|
||||
self.build.issue_build()
|
||||
# Allocate tracked stock to output_1
|
||||
self.allocate_stock(self.output_1, {self.stock_3_1: 6})
|
||||
self.assertGreater(self.output_1.items_to_install.count(), 0)
|
||||
|
||||
scrap_build_outputs(
|
||||
self.build.pk,
|
||||
[{'output_id': self.output_1.pk, 'quantity': self.output_1.quantity}],
|
||||
location_id=self.location.pk,
|
||||
notes='discard test',
|
||||
discard_allocations=True,
|
||||
user_id=None,
|
||||
)
|
||||
|
||||
self.output_1.refresh_from_db()
|
||||
self.assertEqual(self.output_1.status, StockStatus.REJECTED.value)
|
||||
self.assertFalse(self.output_1.is_building)
|
||||
|
||||
# Allocation rows should be gone
|
||||
self.assertEqual(self.output_1.items_to_install.count(), 0)
|
||||
# Stock was discarded (not consumed or installed)
|
||||
self.stock_3_1.refresh_from_db()
|
||||
self.assertIsNone(self.stock_3_1.consumed_by)
|
||||
self.assertIsNone(self.stock_3_1.belongs_to)
|
||||
|
||||
def test_scrap_build_outputs_consume_allocations(self):
|
||||
"""scrap_build_outputs with discard_allocations=False (default) consumes/installs stock."""
|
||||
from build.tasks import scrap_build_outputs
|
||||
|
||||
self.build.issue_build()
|
||||
self.allocate_stock(self.output_1, {self.stock_3_1: 6})
|
||||
|
||||
scrap_build_outputs(
|
||||
self.build.pk,
|
||||
[{'output_id': self.output_1.pk, 'quantity': self.output_1.quantity}],
|
||||
location_id=self.location.pk,
|
||||
notes='consume test',
|
||||
discard_allocations=False,
|
||||
user_id=None,
|
||||
)
|
||||
|
||||
self.output_1.refresh_from_db()
|
||||
self.assertEqual(self.output_1.status, StockStatus.REJECTED.value)
|
||||
self.assertFalse(self.output_1.is_building)
|
||||
|
||||
# complete_allocation splits stock_3_1 and installs the split piece into output_1
|
||||
# (stock_3_1 quantity=1000, only 6 allocated, so a child item is created)
|
||||
self.assertTrue(
|
||||
StockItem.objects.filter(belongs_to=self.output_1).exists(),
|
||||
'Expected a tracked stock item to be installed into the output',
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# complete_build_outputs task
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def test_complete_build_outputs_with_status_none(self):
|
||||
"""complete_build_outputs with status=None falls back to StockStatus.OK in the model."""
|
||||
from build.tasks import complete_build_outputs
|
||||
|
||||
self.build.issue_build()
|
||||
# Create output directly to avoid serial-number requirements on the trackable assembly
|
||||
output = StockItem.objects.create(
|
||||
part=self.assembly, quantity=5, is_building=True, build=self.build
|
||||
)
|
||||
|
||||
complete_build_outputs(
|
||||
self.build.pk,
|
||||
[{'output_id': output.pk}],
|
||||
location_id=self.location.pk,
|
||||
status=None,
|
||||
notes='status none test',
|
||||
user_id=None,
|
||||
)
|
||||
|
||||
output.refresh_from_db()
|
||||
self.assertFalse(output.is_building)
|
||||
# status=None should resolve to StockStatus.OK (the model default)
|
||||
self.assertEqual(output.status, StockStatus.OK.value)
|
||||
|
||||
@@ -402,6 +402,12 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
||||
'choices': barcode_plugins,
|
||||
'default': 'inventreebarcode',
|
||||
},
|
||||
'PART_ENABLE_LOCKING': {
|
||||
'name': _('Part Locking'),
|
||||
'description': _('Enable locking of parts to prevent modification'),
|
||||
'validator': bool,
|
||||
'default': True,
|
||||
},
|
||||
'PART_ENABLE_REVISION': {
|
||||
'name': _('Part Revisions'),
|
||||
'description': _('Enable revision field for Part'),
|
||||
|
||||
@@ -571,13 +571,13 @@ class Part(
|
||||
}
|
||||
|
||||
def check_parameter_delete(self, parameter):
|
||||
"""Custom delete check for Paramteter instances associated with this Part."""
|
||||
if self.locked:
|
||||
"""Custom delete check for Parameter instances associated with this Part."""
|
||||
if self.locked and get_global_setting('PART_ENABLE_LOCKING'):
|
||||
raise ValidationError(_('Cannot delete parameters of a locked part'))
|
||||
|
||||
def check_parameter_save(self, parameter):
|
||||
"""Custom save check for Parameter instances associated with this Part."""
|
||||
if self.locked:
|
||||
if self.locked and get_global_setting('PART_ENABLE_LOCKING'):
|
||||
raise ValidationError(_('Cannot modify parameters of a locked part'))
|
||||
|
||||
def delete(self, **kwargs):
|
||||
@@ -588,7 +588,7 @@ class Part(
|
||||
- The part is still active
|
||||
- The part is used in a BOM for a different part.
|
||||
"""
|
||||
if self.locked:
|
||||
if self.locked and get_global_setting('PART_ENABLE_LOCKING'):
|
||||
raise ValidationError(_('Cannot delete this part as it is locked'))
|
||||
|
||||
if self.active:
|
||||
@@ -4067,7 +4067,8 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
|
||||
Raises:
|
||||
ValidationError: If the assembly is locked
|
||||
"""
|
||||
# TODO: Perhaps control this with a global setting?
|
||||
if not get_global_setting('PART_ENABLE_LOCKING'):
|
||||
return
|
||||
|
||||
if assembly.locked:
|
||||
raise ValidationError(_('BOM item cannot be modified - assembly is locked'))
|
||||
|
||||
@@ -8,6 +8,7 @@ from django.test import TestCase
|
||||
|
||||
import build.models
|
||||
import stock.models
|
||||
from common.settings import set_global_setting
|
||||
|
||||
from .models import BomItem, BomItemSubstitute, Part
|
||||
|
||||
@@ -391,6 +392,44 @@ class BomItemTest(TestCase):
|
||||
# Delete the new BOM item
|
||||
bom_item.delete()
|
||||
|
||||
def test_locked_assembly_locking_disabled(self):
|
||||
"""Test that a locked assembly is not enforced when PART_ENABLE_LOCKING is disabled."""
|
||||
assembly = Part.objects.create(
|
||||
name='Assembly3', description='An assembly part', assembly=True
|
||||
)
|
||||
sub_part = Part.objects.create(
|
||||
name='SubPart2', description='A sub-part', component=True
|
||||
)
|
||||
|
||||
bom_item = BomItem.objects.create(part=assembly, sub_part=sub_part, quantity=1)
|
||||
|
||||
assembly.locked = True
|
||||
assembly.save()
|
||||
|
||||
# With locking enabled (default), editing is blocked
|
||||
with self.assertRaises(django_exceptions.ValidationError):
|
||||
bom_item.quantity = 5
|
||||
bom_item.save()
|
||||
|
||||
# Disable locking globally — all BOM operations should now be allowed
|
||||
set_global_setting('PART_ENABLE_LOCKING', False)
|
||||
|
||||
bom_item.quantity = 5
|
||||
bom_item.save()
|
||||
|
||||
BomItem.objects.create(part=assembly, sub_part=sub_part, quantity=2)
|
||||
|
||||
bom_item.delete()
|
||||
|
||||
# Re-enable for other tests
|
||||
set_global_setting('PART_ENABLE_LOCKING', True)
|
||||
|
||||
# Confirm locking is enforced again
|
||||
bom_item2 = BomItem.objects.get(part=assembly, sub_part=sub_part, quantity=2)
|
||||
with self.assertRaises(django_exceptions.ValidationError):
|
||||
bom_item2.quantity = 99
|
||||
bom_item2.save()
|
||||
|
||||
def test_bom_validated(self):
|
||||
"""Test for caching of 'bom_validated' property."""
|
||||
from part.tasks import validate_bom
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.test import TestCase, TransactionTestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from common.models import InvenTreeSetting, Parameter, ParameterTemplate
|
||||
from common.settings import set_global_setting
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||
|
||||
from .models import Part, PartCategory, PartCategoryParameterTemplate
|
||||
@@ -141,6 +142,43 @@ class TestParams(TestCase):
|
||||
# And we can delete the parameter
|
||||
parameter.delete()
|
||||
|
||||
def test_locked_part_locking_disabled(self):
|
||||
"""Test that parameter restrictions are lifted when PART_ENABLE_LOCKING is disabled."""
|
||||
part = Part.objects.create(
|
||||
name='Test Part Lock Override',
|
||||
description='Part for testing global locking override',
|
||||
category=PartCategory.objects.first(),
|
||||
)
|
||||
|
||||
parameter = Parameter.objects.create(
|
||||
content_object=part, template=ParameterTemplate.objects.first(), data='100'
|
||||
)
|
||||
|
||||
part.locked = True
|
||||
part.save()
|
||||
|
||||
# With locking enabled (default), editing and deletion are blocked
|
||||
with self.assertRaises(django_exceptions.ValidationError):
|
||||
parameter.data = '200'
|
||||
parameter.save()
|
||||
|
||||
with self.assertRaises(django_exceptions.ValidationError):
|
||||
parameter.delete()
|
||||
|
||||
# Disable locking globally — parameter operations should now succeed
|
||||
set_global_setting('PART_ENABLE_LOCKING', False)
|
||||
|
||||
parameter.data = '200'
|
||||
parameter.save()
|
||||
self.assertEqual(parameter.data, '200')
|
||||
|
||||
# Re-enable locking — editing should be blocked again
|
||||
set_global_setting('PART_ENABLE_LOCKING', True)
|
||||
|
||||
with self.assertRaises(django_exceptions.ValidationError):
|
||||
parameter.data = '300'
|
||||
parameter.save()
|
||||
|
||||
|
||||
class TestCategoryTemplates(TransactionTestCase):
|
||||
"""Test class for PartCategoryParameterTemplate model."""
|
||||
|
||||
@@ -353,6 +353,28 @@ class PartTest(TestCase):
|
||||
|
||||
part.delete()
|
||||
|
||||
def test_delete_locking_disabled(self):
|
||||
"""Test that a locked part can be deleted when PART_ENABLE_LOCKING is disabled."""
|
||||
part = Part.objects.create(
|
||||
name='Locked Part Test',
|
||||
description='Part for testing locking override',
|
||||
active=False,
|
||||
)
|
||||
|
||||
part.locked = True
|
||||
part.save()
|
||||
|
||||
# With locking enabled (default), deletion of a locked part raises an error
|
||||
with self.assertRaises(ValidationError):
|
||||
part.delete()
|
||||
|
||||
# Disable locking globally — locked part should now be deletable
|
||||
set_global_setting('PART_ENABLE_LOCKING', False)
|
||||
part.delete()
|
||||
|
||||
# Re-enable for other tests
|
||||
set_global_setting('PART_ENABLE_LOCKING', True)
|
||||
|
||||
def test_revisions(self):
|
||||
"""Test the 'revision' and 'revision_of' field."""
|
||||
template = Part.objects.create(
|
||||
|
||||
@@ -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%'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -188,6 +188,11 @@ export function usePartFields({
|
||||
delete fields['default_expiry'];
|
||||
}
|
||||
|
||||
// Remove "locked" field if locking not enabled
|
||||
if (!globalSettings.isSet('PART_ENABLE_LOCKING')) {
|
||||
delete fields['locked'];
|
||||
}
|
||||
|
||||
if (create) {
|
||||
delete fields['starred'];
|
||||
}
|
||||
|
||||
@@ -212,6 +212,7 @@ export default function SystemSettings() {
|
||||
'PART_ALLOW_DUPLICATE_IPN',
|
||||
'PART_ALLOW_EDIT_IPN',
|
||||
'PART_ALLOW_DELETE_FROM_ASSEMBLY',
|
||||
'PART_ENABLE_LOCKING',
|
||||
'PART_ENABLE_REVISION',
|
||||
'PART_REVISION_ASSEMBLY_ONLY',
|
||||
'PART_SHOW_RELATED',
|
||||
|
||||
@@ -202,6 +202,11 @@ export default function PartDetail() {
|
||||
refetchOnMount: true
|
||||
});
|
||||
|
||||
const lockingEnabled = useMemo(
|
||||
() => globalSettings.isSet('PART_ENABLE_LOCKING'),
|
||||
[globalSettings]
|
||||
);
|
||||
|
||||
const revisionsEnabled = useMemo(
|
||||
() => globalSettings.isSet('PART_ENABLE_REVISION'),
|
||||
[globalSettings]
|
||||
@@ -808,7 +813,12 @@ export default function PartDetail() {
|
||||
icon: <IconTestPipe />,
|
||||
hidden: !part.testable,
|
||||
content: part?.pk ? (
|
||||
<PartTestTemplateTable partId={part?.pk} partLocked={part.locked} />
|
||||
<PartTestTemplateTable
|
||||
partId={part?.pk}
|
||||
partLocked={
|
||||
globalSettings.isSet('PART_ENABLE_LOCKING') && part?.locked
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton />
|
||||
)
|
||||
@@ -836,7 +846,7 @@ export default function PartDetail() {
|
||||
icon: <IconListDetails />,
|
||||
content: (
|
||||
<>
|
||||
{part.locked && (
|
||||
{lockingEnabled && part.locked && (
|
||||
<Alert
|
||||
title={t`Part is Locked`}
|
||||
color='orange'
|
||||
@@ -849,7 +859,7 @@ export default function PartDetail() {
|
||||
<ParameterTable
|
||||
modelType={ModelType.part}
|
||||
modelId={part?.pk}
|
||||
allowEdit={part?.locked != true}
|
||||
allowEdit={!lockingEnabled || part?.locked != true}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
@@ -1149,20 +1159,22 @@ export default function PartDetail() {
|
||||
<PageDetail
|
||||
title={`${t`Part`}: ${part.full_name}`}
|
||||
icon={
|
||||
<ActionIcon
|
||||
aria-label='part-lock-icon'
|
||||
variant='transparent'
|
||||
disabled={!user.hasChangeRole(UserRoles.part)}
|
||||
onClick={() => {
|
||||
api
|
||||
.patch(apiUrl(ApiEndpoints.part_list, part.pk), {
|
||||
locked: !part.locked
|
||||
})
|
||||
.then(refreshInstance);
|
||||
}}
|
||||
>
|
||||
{part?.locked ? <IconLock /> : <IconLockOpen />}
|
||||
</ActionIcon>
|
||||
lockingEnabled ? (
|
||||
<ActionIcon
|
||||
aria-label='part-lock-icon'
|
||||
variant='transparent'
|
||||
disabled={!user.hasChangeRole(UserRoles.part)}
|
||||
onClick={() => {
|
||||
api
|
||||
.patch(apiUrl(ApiEndpoints.part_list, part.pk), {
|
||||
locked: !part.locked
|
||||
})
|
||||
.then(refreshInstance);
|
||||
}}
|
||||
>
|
||||
{part?.locked ? <IconLock /> : <IconLockOpen />}
|
||||
</ActionIcon>
|
||||
) : undefined
|
||||
}
|
||||
subtitle={part.description}
|
||||
imageUrl={part.image}
|
||||
|
||||
@@ -52,6 +52,8 @@ export function RenderPartColumn({
|
||||
part: any;
|
||||
full_name?: boolean;
|
||||
}) {
|
||||
const globalSettings = useGlobalSettingsState.getState();
|
||||
|
||||
if (!part) {
|
||||
return <Skeleton />;
|
||||
}
|
||||
@@ -69,7 +71,7 @@ export function RenderPartColumn({
|
||||
<IconExclamationCircle color='red' size={16} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{part?.locked && (
|
||||
{globalSettings.isSet('PART_ENABLE_LOCKING') && part?.locked && (
|
||||
<Tooltip label={t`Part is Locked`}>
|
||||
<IconLock size={16} />
|
||||
</Tooltip>
|
||||
|
||||
@@ -40,7 +40,10 @@ import {
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useImporterState } from '../../states/ImporterState';
|
||||
import { useUserSettingsState } from '../../states/SettingsStates';
|
||||
import {
|
||||
useGlobalSettingsState,
|
||||
useUserSettingsState
|
||||
} from '../../states/SettingsStates';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import {
|
||||
BooleanColumn,
|
||||
@@ -91,6 +94,13 @@ export function BomTable({
|
||||
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
|
||||
const isLocked = useMemo(
|
||||
() => globalSettings.isSet('PART_ENABLE_LOCKING') && (partLocked ?? false),
|
||||
[globalSettings, partLocked]
|
||||
);
|
||||
|
||||
const userSettings = useUserSettingsState();
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
@@ -605,16 +615,14 @@ export function BomTable({
|
||||
title: t`Validate BOM Line`,
|
||||
color: 'green',
|
||||
hidden:
|
||||
partLocked ||
|
||||
record.validated ||
|
||||
!user.hasChangeRole(UserRoles.bom),
|
||||
isLocked || record.validated || !user.hasChangeRole(UserRoles.bom),
|
||||
icon: <IconCircleCheck />,
|
||||
onClick: () => {
|
||||
validateBomItem(record);
|
||||
}
|
||||
},
|
||||
RowEditAction({
|
||||
hidden: partLocked || !user.hasChangeRole(UserRoles.bom),
|
||||
hidden: isLocked || !user.hasChangeRole(UserRoles.bom),
|
||||
onClick: () => {
|
||||
setSelectedBomItem(record);
|
||||
editBomItem.open();
|
||||
@@ -623,7 +631,7 @@ export function BomTable({
|
||||
{
|
||||
title: t`Edit Substitutes`,
|
||||
color: 'blue',
|
||||
hidden: partLocked || !user.hasAddRole(UserRoles.bom),
|
||||
hidden: isLocked || !user.hasAddRole(UserRoles.bom),
|
||||
icon: <IconSwitch3 />,
|
||||
onClick: () => {
|
||||
setSelectedBomItem(record);
|
||||
@@ -631,7 +639,7 @@ export function BomTable({
|
||||
}
|
||||
},
|
||||
RowDeleteAction({
|
||||
hidden: partLocked || !user.hasDeleteRole(UserRoles.bom),
|
||||
hidden: isLocked || !user.hasDeleteRole(UserRoles.bom),
|
||||
onClick: () => {
|
||||
setSelectedBomItem(record);
|
||||
deleteBomItem.open();
|
||||
@@ -639,7 +647,7 @@ export function BomTable({
|
||||
})
|
||||
];
|
||||
},
|
||||
[isEditing, partId, partLocked, user]
|
||||
[isEditing, partId, isLocked, user]
|
||||
);
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
@@ -649,7 +657,7 @@ export function BomTable({
|
||||
tooltip={t`Add BOM Items`}
|
||||
position='bottom-start'
|
||||
icon={<IconPlus />}
|
||||
hidden={!isEditing || partLocked || !user.hasAddRole(UserRoles.bom)}
|
||||
hidden={!isEditing || isLocked || !user.hasAddRole(UserRoles.bom)}
|
||||
actions={[
|
||||
{
|
||||
name: t`Add BOM Item`,
|
||||
@@ -667,7 +675,7 @@ export function BomTable({
|
||||
/>,
|
||||
<ActionButton
|
||||
key='edit-bom'
|
||||
hidden={partLocked || !user.hasChangeRole(UserRoles.bom) || isEditing}
|
||||
hidden={isLocked || !user.hasChangeRole(UserRoles.bom) || isEditing}
|
||||
tooltip={t`Edit BOM`}
|
||||
icon={<IconEdit />}
|
||||
onClick={() => {
|
||||
@@ -686,7 +694,7 @@ export function BomTable({
|
||||
}}
|
||||
/>
|
||||
];
|
||||
}, [isEditing, partLocked, user]);
|
||||
}, [isEditing, isLocked, user]);
|
||||
|
||||
// Row expansion (for displaying subassemblies)
|
||||
const rowExpansion = subassemblyRowExpansion({ table: table });
|
||||
@@ -699,7 +707,7 @@ export function BomTable({
|
||||
{deleteBomItem.modal}
|
||||
{editSubstitutes.modal}
|
||||
<Stack gap='xs'>
|
||||
{partLocked && (
|
||||
{isLocked && (
|
||||
<Alert
|
||||
title={t`Part is Locked`}
|
||||
color='orange'
|
||||
@@ -730,9 +738,9 @@ export function BomTable({
|
||||
modelField: 'sub_part',
|
||||
onCellClick: () => {},
|
||||
rowActions: isEditing ? rowActions : undefined,
|
||||
enableSelection: isEditing && !partLocked,
|
||||
enableSelection: isEditing && !isLocked,
|
||||
enableBulkDelete:
|
||||
isEditing && !partLocked && user.hasDeleteRole(UserRoles.bom),
|
||||
isEditing && !isLocked && user.hasDeleteRole(UserRoles.bom),
|
||||
enableDownload: true,
|
||||
rowExpansion: isEditing ? undefined : rowExpansion
|
||||
}}
|
||||
|
||||
@@ -346,31 +346,80 @@ export default function BuildOutputTable({
|
||||
|
||||
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({
|
||||
build: build,
|
||||
outputs: selectedOutputs,
|
||||
hasTrackedItems: hasTrackedItems,
|
||||
onFormSuccess: () => {
|
||||
table.refreshTable(true);
|
||||
refreshBuild();
|
||||
onFormSuccess: (response: any) => {
|
||||
if (response.task_id) {
|
||||
setCompleteTaskId(response.task_id);
|
||||
} else {
|
||||
// If no task ID is returned, immediately refresh the table and build data
|
||||
table.refreshTable(true);
|
||||
refreshBuild();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const scrapBuildOutputsForm = useScrapBuildOutputsForm({
|
||||
build: build,
|
||||
outputs: selectedOutputs,
|
||||
onFormSuccess: () => {
|
||||
table.refreshTable(true);
|
||||
refreshBuild();
|
||||
onFormSuccess: (response: any) => {
|
||||
if (response.task_id) {
|
||||
setScrapTaskId(response.task_id);
|
||||
} else {
|
||||
// If no task ID is returned, immediately refresh the table and build data
|
||||
table.refreshTable(true);
|
||||
refreshBuild();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const cancelBuildOutputsForm = useCancelBuildOutputsForm({
|
||||
build: build,
|
||||
outputs: selectedOutputs,
|
||||
onFormSuccess: () => {
|
||||
table.refreshTable(true);
|
||||
refreshBuild();
|
||||
onFormSuccess: (response: any) => {
|
||||
if (response.task_id) {
|
||||
setDeleteTaskId(response.task_id);
|
||||
} else {
|
||||
// If no task ID is returned, immediately refresh the table and build data
|
||||
table.refreshTable(true);
|
||||
refreshBuild();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -207,8 +207,7 @@ export function PurchaseOrderLineItemTable({
|
||||
{
|
||||
accessor: 'received',
|
||||
title: t`Received`,
|
||||
sortable: false,
|
||||
|
||||
sortable: true,
|
||||
render: (record: any) => (
|
||||
<ProgressBar
|
||||
progressLabel={true}
|
||||
|
||||
@@ -38,6 +38,7 @@ test('Build Order - Basic Tests', async ({ browser }) => {
|
||||
await clearTableFilters(page);
|
||||
|
||||
// We have now loaded the "Build Order" table. Check for some expected texts
|
||||
await page.getByPlaceholder('Search').fill('7');
|
||||
await page.getByText('On Hold').first().waitFor();
|
||||
await page.getByText('Pending').first().waitFor();
|
||||
|
||||
@@ -60,6 +61,7 @@ test('Build Order - Basic Tests', async ({ browser }) => {
|
||||
await page.getByLabel('breadcrumb-0-manufacturing').click();
|
||||
|
||||
// Load a different build order
|
||||
await page.getByPlaceholder('Search').fill('11');
|
||||
await page.getByRole('cell', { name: 'BO0011' }).click();
|
||||
|
||||
// This build order should be "in production"
|
||||
@@ -654,6 +656,7 @@ test('Build Order - Filters', async ({ browser }) => {
|
||||
// Check for expected pagination text i.e. (1 - 24 / 24)
|
||||
// Note: Due to other concurrent tests, the number of build orders may vary
|
||||
await page.getByText(/1 - \d+ \/ \d+/).waitFor();
|
||||
await page.getByPlaceholder('Search').fill('23');
|
||||
await page.getByRole('cell', { name: 'BO0023' }).waitFor();
|
||||
|
||||
// Toggle 'Outstanding' filter
|
||||
@@ -665,7 +668,7 @@ test('Build Order - Filters', async ({ browser }) => {
|
||||
await page.getByRole('textbox', { name: 'table-search-input' }).fill('');
|
||||
await setTableChoiceFilter(page, 'Outstanding', 'No');
|
||||
|
||||
await page.getByText('1 - 6 / 6').waitFor();
|
||||
await page.getByText(/1 - \d+ \/ \d+/).waitFor();
|
||||
|
||||
await clearTableFilters(page);
|
||||
|
||||
@@ -699,6 +702,40 @@ test('Build Order - Duplicate', async ({ browser }) => {
|
||||
await page.getByRole('tab', { name: 'Build Details' }).click();
|
||||
|
||||
await page.getByText('Pending').first().waitFor();
|
||||
|
||||
// Create a build output
|
||||
await loadTab(page, 'Incomplete Outputs');
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-add-build-output' })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('textbox', { name: 'text-field-batch_code' })
|
||||
.fill('BATCH-001');
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Cancel (delete) the build output
|
||||
const cell = await page.getByRole('cell', { name: 'BATCH-001' }).first();
|
||||
await clickOnRowMenu(cell);
|
||||
await page.getByRole('menuitem', { name: 'Cancel' }).click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// no more build outputs
|
||||
await page.getByText('No records found').waitFor();
|
||||
|
||||
// Cancel the build
|
||||
await page.getByRole('button', { name: 'action-menu-build-order-' }).click();
|
||||
await page
|
||||
.getByRole('menuitem', { name: 'action-menu-build-order-actions-cancel' })
|
||||
.click();
|
||||
|
||||
await page
|
||||
.getByRole('switch', { name: 'boolean-field-remove_allocated_stock' })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('switch', { name: 'boolean-field-remove_incomplete_outputs' })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Cancelled').first().waitFor();
|
||||
});
|
||||
|
||||
// Tests for external build orders
|
||||
|
||||
Reference in New Issue
Block a user