diff --git a/CHANGELOG.md b/CHANGELOG.md index a7970539d2..3c200417ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- [#11527](https://github.com/inventree/InvenTree/pull/11527) adds a new API endpoint for monitoring the status of a particular background task. This endpoint allows clients to check the status of a background task and receive updates when the task is complete. This is useful for long-running tasks that may take some time to complete, allowing clients to provide feedback to users about the progress of the task. - [#11405](https://github.com/inventree/InvenTree/pull/11405) adds default table filters, which hide inactive items by default. The default table filters are overridden by user filter selection, and only apply to the table view initially presented to the user. This means that users can still view inactive items if they choose to, but they will not be shown by default. - [#11222](https://github.com/inventree/InvenTree/pull/11222) adds support for data import using natural keys, allowing for easier association of related objects without needing to know their internal database IDs. - [#11383](https://github.com/inventree/InvenTree/pull/11383) adds "exists_for_model_id", "exists_for_related_model", and "exists_for_related_model_id" filters to the ParameterTemplate API endpoint. These filters allow users to check for the existence of parameters associated with specific models or related models, improving the flexibility and usability of the API. diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 9a0cd731cd..3525ce8a6a 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 463 +INVENTREE_API_VERSION = 464 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v464 -> 2026-03-15 : https://github.com/inventree/InvenTree/pull/11527 + - Add API endpoint for monitoring the progress of a particular background task + v463 -> 2026-03-12 : https://github.com/inventree/InvenTree/pull/11499 - Allow "bulk update" actions against StockItem endpoint diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index 0d8831f365..c5a86a9857 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -953,6 +953,7 @@ Q_CLUSTER = { 'max_attempts': int( get_setting('INVENTREE_BACKGROUND_MAX_ATTEMPTS', 'background.max_attempts', 5) ), + 'save_limit': 1000, 'queue_limit': 50, 'catch_up': False, 'bulk': 10, diff --git a/src/backend/InvenTree/InvenTree/tasks.py b/src/backend/InvenTree/InvenTree/tasks.py index 887c11899d..31c75b9c8f 100644 --- a/src/backend/InvenTree/InvenTree/tasks.py +++ b/src/backend/InvenTree/InvenTree/tasks.py @@ -161,13 +161,20 @@ def record_task_success(task_name: str): def offload_task( taskname, *args, force_async=False, force_sync=False, **kwargs -) -> bool: +) -> str | bool: """Create an AsyncTask if workers are running. This is different to a 'scheduled' task, in that it only runs once! If workers are not running or force_sync flag, is set then the task is ran synchronously. + Arguments: + taskname: The name of the task to be run, in the format 'app.module.function' + *args: Positional arguments to be passed to the task function + force_async: If True, force the task to be offloaded (even if workers are not running) + force_sync: If True, force the task to be run synchronously (even if workers are running) + **kwargs: Keyword arguments to be passed to the task function + Returns: - bool: True if the task was offloaded (or ran), False otherwise + str | bool: Task ID if the task was offloaded, True if ran synchronously, False otherwise """ from InvenTree.exceptions import log_error @@ -203,6 +210,9 @@ def offload_task( task = AsyncTask(taskname, *args, group=group, **kwargs) with tracer.start_as_current_span(f'async worker: {taskname}'): task.run() + + # Return the ID of the offloaded task, so that it can be tracked if needed + return task.id except ImportError: raise_warning(f"WARNING: '{taskname}' not offloaded - Function not found") return False @@ -265,6 +275,40 @@ def offload_task( return True +def get_queued_task(task_id: str): + """Find the task in the queue, if it exists. + + Note that the OrmQ table does NOT keep the task ID as a database field, + it is instead stored in the payload data. + If there are a large number of pending tasks, this query may be inefficient, + but there is no other way to find a queued task by ID. + """ + offset = 0 + limit = 500 + + if not task_id: + # Return early if no task ID was provided + return None + + task_id = str(task_id) + + from django_q.models import OrmQ + + while True: + queued_tasks = OrmQ.objects.all().order_by('id')[offset : offset + limit] + if not queued_tasks: + break + + for task in queued_tasks: + if task.task_id() == task_id: + return task + + offset += limit + + # No matching task was discovered + return None + + @dataclass() class ScheduledTask: """A scheduled task. diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 69f41e80ff..802911586a 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -17,8 +17,8 @@ from django.views.decorators.csrf import csrf_exempt import django_filters.rest_framework.filters as rest_filters import django_q.models +import django_q.tasks from django_filters.rest_framework.filterset import FilterSet -from django_q.tasks import async_task from djmoney.contrib.exchange.models import ExchangeBackend, Rate from drf_spectacular.utils import OpenApiResponse, extend_schema from error_report.models import Error @@ -115,7 +115,7 @@ class WebhookView(CsrfExemptMixin, APIView): # process data message = self.webhook.save_data(payload, headers, request) if self.run_async: - async_task(self._process_payload, message.id) + django_q.tasks.async_task(self._process_payload, message.id) else: self._process_result( self.webhook.process_payload(message, payload, headers), message @@ -564,13 +564,29 @@ class ErrorMessageDetail(RetrieveUpdateDestroyAPI): permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser] +class BackgroundTaskDetail(APIView): + """Detail view for a single background task.""" + + permission_classes = [IsAuthenticatedOrReadScope] + + @extend_schema(responses={200: common.serializers.TaskDetailSerializer}) + def get(self, request, task_id, *args, **kwargs): + """Fetch information regarding a particular background task ID.""" + response = common.serializers.TaskDetailSerializer.from_task(task_id).data + + return Response(response, status=response['http_status']) + + class BackgroundTaskOverview(APIView): """Provides an overview of the background task queue status.""" permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser] serializer_class = None - @extend_schema(responses={200: common.serializers.TaskOverviewSerializer}) + @extend_schema( + operation_id='background_task_overview', + responses={200: common.serializers.TaskOverviewSerializer}, + ) def get(self, request, fmt=None): """Return information about the current status of the background task queue.""" import django_q.models as q_models @@ -1396,6 +1412,9 @@ common_api_urls = [ name='api-scheduled-task-list', ), path('failed/', FailedTaskList.as_view(), name='api-failed-task-list'), + path( + '/', BackgroundTaskDetail.as_view(), name='api-task-detail' + ), path('', BackgroundTaskOverview.as_view(), name='api-task-overview'), ]), ), diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index ae4278a3d1..2cb3473e15 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -522,6 +522,78 @@ class ErrorMessageSerializer(InvenTreeModelSerializer): read_only_fields = ['when', 'info', 'data', 'path', 'pk'] +class TaskDetailSerializer(serializers.Serializer): + """Serializer for a background task detail.""" + + task_id = serializers.CharField(read_only=True) + exists = serializers.BooleanField(read_only=True) + pending = serializers.BooleanField(read_only=True) + complete = serializers.BooleanField(read_only=True) + success = serializers.BooleanField(read_only=True) + http_status = serializers.IntegerField(read_only=True) + + @classmethod + def from_task(cls, task_id: str | bool | None) -> 'TaskDetailSerializer': + """Create a TaskDetailSerializer instance from a django_q Task. + + Arguments: + task_id: The ID of the task to retrieve details for. + + Returns: + An instance of TaskDetailSerializer with the task details. + + Notes: + - If the provided task_id is None, the task has not been run, or has errored out + - If the provided task_id is a boolean, the task has been run synchronously, and the boolean value indicates success or failure + - If the provided task_id is a string, the task has been offloaded to the background worker, and the details can be from the database + + """ + from InvenTree.tasks import get_queued_task + + if task_id is None or type(task_id) is bool: + # If the task_id is a boolean, the task has been run synchronously + return cls({ + 'task_id': '', + 'exists': False, + 'pending': False, + 'complete': task_id is not None, + 'success': False if task_id is None else bool(task_id), + 'http_status': 404 if task_id is None else 200, + }) + + # A non-boolean result indicates that the task has been offloaded to the background worker + success = django_q.models.Success.objects.filter(id=task_id).first() + failure = django_q.models.Failure.objects.filter(id=task_id).first() + task = ( + success + or failure + or django_q.models.Task.objects.filter(id=task_id).first() + ) + queued = False + + exists = bool(success or failure or task) + + if not exists: + # If the task has not been started yet, it may be present in the queue + queued = bool(get_queued_task(task_id)) + + complete = bool(success) or bool(failure) + + # Determine the http_status code for the task + # - 200: Task exists and has been completed + # - 404: Task does not exist + http_status = 200 if exists or queued else 404 + + return cls({ + 'task_id': task_id, + 'exists': exists or queued, + 'pending': queued, + 'complete': complete, + 'success': bool(success), + 'http_status': http_status, + }) + + class TaskOverviewSerializer(serializers.Serializer): """Serializer for background task overview.""" diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py index 7df489209a..6255c867ab 100644 --- a/src/backend/InvenTree/common/tests.py +++ b/src/backend/InvenTree/common/tests.py @@ -1107,6 +1107,41 @@ class TaskListApiTests(InvenTreeAPITestCase): for task in response.data: self.assertEqual(task['name'], 'time.sleep') + def test_task_detail(self): + """Test the BackgroundTaskDetail API endpoint.""" + from InvenTree.tasks import offload_task + + # Force run a task + result = offload_task('fake_module.test_task', force_sync=True) + self.assertFalse(result) + self.assertEqual(type(result), bool) + + # Schedule a dummy task - and ensure it offloads to the worker + task_id = offload_task('fake_module.test_task', force_async=True) + self.assertIsNotNone(task_id) + self.assertEqual(type(task_id), str) + + url = reverse('api-task-detail', kwargs={'task_id': task_id}) + + data = self.get(url, expected_code=200).data + + self.assertEqual(data['task_id'], task_id) + self.assertTrue(data['exists']) + self.assertTrue(data['pending']) + self.assertFalse(data['complete']) + self.assertFalse(data['success']) + + # Perform a lookup for a non-existent task + url = reverse('api-task-detail', kwargs={'task_id': 'doesnotexist'}) + + data = self.get(url, expected_code=404).data + + self.assertEqual(data['task_id'], 'doesnotexist') + self.assertFalse(data['exists']) + self.assertFalse(data['pending']) + self.assertFalse(data['complete']) + self.assertFalse(data['success']) + class WebhookMessageTests(TestCase): """Tests for webhooks.""" diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index 31169bf878..9303c1f053 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -8,10 +8,11 @@ import django_filters.rest_framework.filters as rest_filters from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework.filterset import FilterSet from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import extend_schema_field +from drf_spectacular.utils import extend_schema, extend_schema_field from rest_framework import serializers from rest_framework.response import Response +import common.serializers import part.tasks as part_tasks from data_exporter.mixins import DataExportViewMixin from InvenTree.api import ( @@ -614,8 +615,18 @@ class PartValidateBOM(RetrieveUpdateAPI): queryset = Part.objects.all() serializer_class = part_serializers.PartBomValidateSerializer + @extend_schema( + responses={ + 200: common.serializers.TaskDetailSerializer, + 404: common.serializers.TaskDetailSerializer, + } + ) def update(self, request, *args, **kwargs): - """Validate the referenced BomItem instance.""" + """Validate the referenced BomItem instance. + + As this task if offloaded to the background worker, + we return information about the background task which is performing the validation. + """ part = self.get_object() partial = kwargs.pop('partial', False) @@ -629,7 +640,7 @@ class PartValidateBOM(RetrieveUpdateAPI): valid = str2bool(serializer.validated_data.get('valid', False)) # BOM validation may take some time, so we offload it to a background task - offload_task( + task_id = offload_task( part_tasks.validate_bom, part.pk, valid, @@ -637,10 +648,8 @@ class PartValidateBOM(RetrieveUpdateAPI): group='part', ) - # Re-serialize the response - serializer = self.get_serializer(part, many=False) - - return Response(serializer.data) + response = common.serializers.TaskDetailSerializer.from_task(task_id).data + return Response(response, status=response['http_status']) class PartFilter(FilterSet): diff --git a/src/frontend/CHANGELOG.md b/src/frontend/CHANGELOG.md index 0abe69954f..c3c4341132 100644 --- a/src/frontend/CHANGELOG.md +++ b/src/frontend/CHANGELOG.md @@ -2,6 +2,12 @@ This file contains historical changelog information for the InvenTree UI components library. +### 0.9.0 - March 2026 + +Exposes the `useMonitorBackgroundTask` hook, which allows plugins to monitor the status of a background task and display notifications when the task is complete. This is useful for plugins that offload long-running tasks to the background and want to provide feedback to the user when the task is complete. + +Renames the `monitorDataOutput` hook to `useMonitorDataOutput` to better reflect the fact that this is a React hook, and to provide a more consistent naming convention for hooks in the library. + ### 0.8.2 - March 2026 Bug fixes for the `monitorDataOutput` hook - https://github.com/inventree/InvenTree/pull/11458 diff --git a/src/frontend/lib/hooks/MonitorBackgroundTask.tsx b/src/frontend/lib/hooks/MonitorBackgroundTask.tsx new file mode 100644 index 0000000000..9f4c5f43ba --- /dev/null +++ b/src/frontend/lib/hooks/MonitorBackgroundTask.tsx @@ -0,0 +1,119 @@ +import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; +import { apiUrl } from '@lib/functions/Api'; +import { useDocumentVisibility } from '@mantine/hooks'; +import { notifications, showNotification } from '@mantine/notifications'; +import { + IconCircleCheck, + IconCircleX, + IconExclamationCircle +} from '@tabler/icons-react'; +import { type QueryClient, useQuery } from '@tanstack/react-query'; +import type { AxiosInstance } from 'axios'; +import { useEffect, useState } from 'react'; +import { queryClient } from '../../src/App'; + +export type MonitorBackgroundTaskProps = { + api: AxiosInstance; + queryClient?: QueryClient; + title?: string; + message: string; + errorMessage?: string; + successMessage?: string; + failureMessage?: string; + taskId?: string; + onSuccess?: () => void; + onFailure?: () => void; + onComplete?: () => void; + onError?: (error: Error) => void; +}; + +/** + * Hook for monitoring a background task running on the server + */ +export default function useMonitorBackgroundTask( + props: MonitorBackgroundTaskProps +) { + const visibility = useDocumentVisibility(); + + const [tracking, setTracking] = useState(false); + + useEffect(() => { + if (!!props.taskId) { + setTracking(true); + showNotification({ + id: `background-task-${props.taskId}`, + title: props.title, + message: props.message, + loading: true, + autoClose: false, + withCloseButton: false + }); + } else { + setTracking(false); + } + }, [props.taskId]); + + useQuery( + { + enabled: !!props.taskId && tracking && visibility === 'visible', + refetchInterval: 500, + queryKey: ['background-task', props.taskId], + queryFn: () => + props.api + .get(apiUrl(ApiEndpoints.task_overview, props.taskId)) + .then((response) => { + const data = response?.data ?? {}; + + if (data.complete) { + setTracking(false); + props.onComplete?.(); + + notifications.update({ + id: `background-task-${props.taskId}`, + title: props.title, + loading: false, + color: data.success ? 'green' : 'red', + message: response.data?.success + ? (props.successMessage ?? props.message) + : (props.failureMessage ?? props.message), + icon: response.data?.success ? ( + + ) : ( + + ), + autoClose: 1000, + withCloseButton: true + }); + + if (data.success) { + props.onSuccess?.(); + } else { + props.onFailure?.(); + } + } + + return response; + }) + .catch((error) => { + console.error( + `Error fetching background task status for task ${props.taskId}:`, + error + ); + setTracking(false); + props.onError?.(error); + + notifications.update({ + id: `background-task-${props.taskId}`, + title: props.title, + loading: false, + color: 'red', + message: props.errorMessage ?? props.message, + icon: , + autoClose: 5000, + withCloseButton: true + }); + }) + }, + queryClient + ); +} diff --git a/src/frontend/lib/hooks/MonitorDataOutput.tsx b/src/frontend/lib/hooks/MonitorDataOutput.tsx index dd75d846b5..dd66758416 100644 --- a/src/frontend/lib/hooks/MonitorDataOutput.tsx +++ b/src/frontend/lib/hooks/MonitorDataOutput.tsx @@ -9,48 +9,44 @@ import { ProgressBar } from '../components/ProgressBar'; import { ApiEndpoints } from '../enums/ApiEndpoints'; import { apiUrl } from '../functions/Api'; -/** - * Hook for monitoring a data output process running on the server - */ -export default function monitorDataOutput({ - api, - queryClient, - title, - hostname, - id -}: { +export type MonitorDataOutputProps = { api: AxiosInstance; queryClient?: QueryClient; title: string; hostname?: string; id?: number; -}) { +}; + +/** + * Hook for monitoring a data output process running on the server + */ +export default function useMonitorDataOutput(props: MonitorDataOutputProps) { const visibility = useDocumentVisibility(); const [loading, setLoading] = useState(false); useEffect(() => { - if (!!id) { + if (!!props.id) { setLoading(true); showNotification({ - id: `data-output-${id}`, - title: title, + id: `data-output-${props.id}`, + title: props.title, loading: true, autoClose: false, withCloseButton: false, message: }); } else setLoading(false); - }, [id, title]); + }, [props.id, props.title]); useQuery( { - enabled: !!id && loading && visibility === 'visible', + enabled: !!props.id && loading && visibility === 'visible', refetchInterval: 500, - queryKey: ['data-output', id, title], + queryKey: ['data-output', props.id, props.title], queryFn: () => - api - .get(apiUrl(ApiEndpoints.data_output, id)) + props.api + .get(apiUrl(ApiEndpoints.data_output, props.id)) .then((response) => { const data = response?.data ?? {}; @@ -61,21 +57,21 @@ export default function monitorDataOutput({ data?.error ?? data?.errors?.error ?? t`Process failed`; notifications.update({ - id: `data-output-${id}`, + id: `data-output-${props.id}`, loading: false, icon: , autoClose: 2500, - title: title, + title: props.title, message: error, color: 'red' }); } else if (data.complete) { setLoading(false); notifications.update({ - id: `data-output-${id}`, + id: `data-output-${props.id}`, loading: false, autoClose: 2500, - title: title, + title: props.title, message: t`Process completed successfully`, color: 'green', icon: @@ -83,7 +79,7 @@ export default function monitorDataOutput({ if (data.output) { const url = data.output; - const base = hostname ?? window.location.origin; + const base = props.hostname ?? window.location.origin; const downloadUrl = new URL(url, base); @@ -91,7 +87,7 @@ export default function monitorDataOutput({ } } else { notifications.update({ - id: `data-output-${id}`, + id: `data-output-${props.id}`, loading: true, autoClose: false, withCloseButton: false, @@ -110,19 +106,19 @@ export default function monitorDataOutput({ return data; }) .catch((error: Error) => { - console.error('Error in monitorDataOutput:', error); + console.error('Error in useMonitorDataOutput:', error); setLoading(false); notifications.update({ - id: `data-output-${id}`, + id: `data-output-${props.id}`, loading: false, autoClose: 2500, - title: title, + title: props.title, message: error.message || t`Process failed`, color: 'red' }); return {}; }) }, - queryClient + props.queryClient ); } diff --git a/src/frontend/lib/index.ts b/src/frontend/lib/index.ts index 625e65c436..f9812e6df7 100644 --- a/src/frontend/lib/index.ts +++ b/src/frontend/lib/index.ts @@ -73,4 +73,11 @@ export { } from './components/RowActions'; // Shared hooks -export { default as monitorDataOutput } from './hooks/MonitorDataOutput'; +export { + default as useMonitorDataOutput, + type MonitorDataOutputProps +} from './hooks/MonitorDataOutput'; +export { + default as useMonitorBackgroundTask, + type MonitorBackgroundTaskProps +} from './hooks/MonitorBackgroundTask'; diff --git a/src/frontend/package.json b/src/frontend/package.json index e1e2e76476..229a2d350c 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -1,7 +1,7 @@ { "name": "@inventreedb/ui", "description": "UI components for the InvenTree project", - "version": "0.8.2", + "version": "0.9.0", "private": false, "type": "module", "license": "MIT", diff --git a/src/frontend/playwright.config.ts b/src/frontend/playwright.config.ts index 93e298eb7e..6cc8bf43f4 100644 --- a/src/frontend/playwright.config.ts +++ b/src/frontend/playwright.config.ts @@ -96,6 +96,15 @@ export default defineConfig({ stdout: 'pipe', stderr: 'pipe', timeout: 120 * 1000 + }, + { + command: 'invoke worker', + env: { + INVENTREE_DEBUG: 'True', + INVENTREE_LOG_LEVEL: 'INFO', + INVENTREE_PLUGINS_ENABLED: 'True', + INVENTREE_PLUGINS_MANDATORY: 'samplelocate' + } } ], globalSetup: './playwright/global-setup.ts', diff --git a/src/frontend/src/hooks/UseBackgroundTask.tsx b/src/frontend/src/hooks/UseBackgroundTask.tsx new file mode 100644 index 0000000000..aab807b02f --- /dev/null +++ b/src/frontend/src/hooks/UseBackgroundTask.tsx @@ -0,0 +1,18 @@ +import useMonitorBackgroundTask, { + type MonitorBackgroundTaskProps +} from '@lib/hooks/MonitorBackgroundTask'; +import { useApi } from '../contexts/ApiContext'; + +/** + * Hook for monitoring the progress of a background task running on the server + */ +export default function useBackgroundTask( + props: Omit +) { + const api = useApi(); + + return useMonitorBackgroundTask({ + ...props, + api: api + }); +} diff --git a/src/frontend/src/hooks/UseDataOutput.tsx b/src/frontend/src/hooks/UseDataOutput.tsx index 0dbc622d62..8210a2276e 100644 --- a/src/frontend/src/hooks/UseDataOutput.tsx +++ b/src/frontend/src/hooks/UseDataOutput.tsx @@ -1,4 +1,4 @@ -import monitorDataOutput from '@lib/hooks/MonitorDataOutput'; +import useMonitorDataOutput from '@lib/hooks/MonitorDataOutput'; import { useApi } from '../contexts/ApiContext'; import { useLocalState } from '../states/LocalState'; @@ -15,7 +15,7 @@ export default function useDataOutput({ const api = useApi(); const { getHost } = useLocalState.getState(); - return monitorDataOutput({ + return useMonitorDataOutput({ api: api, title: title, id: id, diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/TaskManagementPanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/TaskManagementPanel.tsx index d839ee7e8f..706ecfccec 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/TaskManagementPanel.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/TaskManagementPanel.tsx @@ -3,6 +3,7 @@ import { Accordion, Alert, Divider, Stack, Text } from '@mantine/core'; import { lazy } from 'react'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; +import { IconCircleCheck, IconExclamationCircle } from '@tabler/icons-react'; import { StylishText } from '../../../../components/items/StylishText'; import { errorCodeLink } from '../../../../components/nav/Alerts'; import { FactCollection } from '../../../../components/settings/FactCollection'; @@ -26,8 +27,18 @@ export default function TaskManagementPanel() { return ( <> - {taskInfo?.is_running == false && ( - + {taskInfo?.is_running ? ( + } + /> + ) : ( + } + > {t`The background task manager service is not running. Contact your system administrator.`} {errorCodeLink('INVE-W5')} diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 7551ba964f..ac48b256f1 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -81,6 +81,7 @@ import { useApi } from '../../contexts/ApiContext'; import { formatDecimal, formatPriceRange } from '../../defaults/formatters'; import { usePartFields } from '../../forms/PartForms'; import { useFindSerialNumberForm } from '../../forms/StockForms'; +import useBackgroundTask from '../../hooks/UseBackgroundTask'; import { useApiFormModal, useCreateApiFormModal, @@ -168,6 +169,17 @@ function BomValidationInformation({ refetchOnMount: true }); + const [taskId, setTaskId] = useState(''); + + useBackgroundTask({ + taskId: taskId, + message: t`Validating BOM`, + successMessage: t`BOM validated`, + onComplete: () => { + bomInformationQuery.refetch(); + } + }); + const validateBom = useApiFormModal({ url: ApiEndpoints.bom_validate, method: 'PUT', @@ -184,9 +196,14 @@ function BomValidationInformation({ {t`Do you want to validate the bill of materials for this assembly?`} ), - successMessage: t`Bill of materials scheduled for validation`, - onFormSuccess: () => { - bomInformationQuery.refetch(); + successMessage: null, + onFormSuccess: (response: any) => { + // If the process has been offloaded to a background task + if (response.task_id) { + setTaskId(response.task_id); + } else { + bomInformationQuery.refetch(); + } } }); diff --git a/src/frontend/tests/pages/pui_part.spec.ts b/src/frontend/tests/pages/pui_part.spec.ts index 42b0e28a6f..c222cf4bbb 100644 --- a/src/frontend/tests/pages/pui_part.spec.ts +++ b/src/frontend/tests/pages/pui_part.spec.ts @@ -140,6 +140,44 @@ test('Parts - BOM', async ({ browser }) => { await page.getByRole('button', { name: 'Close' }).click(); }); +/** + * Perform BOM validation process + * Note that this is a "background task" which is monitored by the "useBackgroundTask" hook + */ +test('Parts - BOM Validation', async ({ browser }) => { + const page = await doCachedLogin(browser, { url: 'part/107/bom' }); + + // Run BOM validation step + await page + .getByRole('button', { name: 'action-button-validate-bom' }) + .click(); + await page.getByRole('button', { name: 'Submit' }).click(); + + // Background task monitoring + await page.getByText('Validating BOM').waitFor(); + await page.getByText('BOM validated').waitFor(); + + await page.getByRole('button', { name: 'bom-validation-info' }).hover(); + await page.getByText('Validated By: allaccessAlly').waitFor(); + + // Edit line item, to ensure BOM is not valid next time around + const cell = await page.getByRole('cell', { name: 'Red paint Red Paint' }); + await clickOnRowMenu(cell); + await page.getByRole('menuitem', { name: 'Edit', exact: true }).click(); + + const input = await page.getByRole('textbox', { + name: 'number-field-quantity' + }); + + const value = await input.inputValue(); + + const nextValue = Number.parseFloat(value) + 0.24; + + await input.fill(`${nextValue.toFixed(3)}`); + await page.getByRole('button', { name: 'Submit' }).click(); + await page.getByText('BOM item updated').waitFor(); +}); + test('Parts - Editing', async ({ browser }) => { const page = await doCachedLogin(browser, { url: 'part/104/details' }); diff --git a/src/frontend/tests/pui_settings.spec.ts b/src/frontend/tests/pui_settings.spec.ts index fefa301e80..35a0975bcd 100644 --- a/src/frontend/tests/pui_settings.spec.ts +++ b/src/frontend/tests/pui_settings.spec.ts @@ -1,3 +1,4 @@ +import type { Page } from '@playwright/test'; import { createApi } from './api.js'; import { expect, test } from './baseFixtures.js'; import { adminuser, allaccessuser, stevenuser } from './defaults.js'; @@ -314,6 +315,29 @@ test('Settings - Admin', async ({ browser }) => { await page.getByRole('button', { name: 'Submit' }).click(); }); +test('Settings - Admin - Background Tasks', async ({ browser }) => { + const page = await doCachedLogin(browser, { + user: adminuser, + url: 'settings/admin/background' + }); + + // Background worker should be running, and idle + await page.getByText('Background worker running').waitFor(); + await page.getByText('Failed Tasks0').waitFor(); + await page.getByText('Pending Tasks0').waitFor(); + + // Expand the "scheduled tasks" view + await page.getByRole('button', { name: 'Scheduled Tasks' }).click(); + + // Check for some expected values + await page + .getByRole('cell', { name: 'InvenTree.tasks.delete_successful_tasks' }) + .waitFor(); + await page + .getByRole('cell', { name: 'InvenTree.tasks.check_for_migrations' }) + .waitFor(); +}); + test('Settings - Admin - Barcode History', async ({ browser }) => { // Login with admin credentials const page = await doCachedLogin(browser, { @@ -529,7 +553,7 @@ test('Settings - Auth - Email', async ({ browser }) => { await page.getByText('Currently no email addresses are registered').waitFor(); }); -async function testColorPicker(page, ref: string) { +async function testColorPicker(page: Page, ref: string) { const element = page.getByLabel(ref); await element.click(); const box = (await element.boundingBox())!; @@ -539,8 +563,7 @@ async function testColorPicker(page, ref: string) { test('Settings - Auth - Tokens', async ({ browser }) => { const page = await doCachedLogin(browser, { - username: 'allaccess', - password: 'nolimits', + user: allaccessuser, url: 'settings/user/' });