Tasks API Endpoint (#6230)

* Add API endpoint for background task overview

* Cleanup other pending heartbeat tasks

* Adds API endpoint for queued tasks

* Adds API endpoint for scheduled tasks

* Add API endpoint for failed tasks

* Update API version info

* Add table for displaying pending tasks

* Add failed tasks table

* Use accordion

* Annotate extra data to scheduled tasks serializer

* Extend API functionality

* Update tasks.py

- Allow skipping of static file step in "invoke update"
- Allows for quicker updates in dev mode

* Display task result error for failed tasks

* Allow delete of failed tasks

* Remove old debug message

* Adds ability to delete pending tasks

* Update table columns

* Fix unused imports

* Prevent multiple heartbeat functions from being added to the queue at startup

* Add unit tests for API
This commit is contained in:
Oliver 2024-01-17 07:10:42 +11:00 committed by GitHub
parent 5c7d3af150
commit 75f75ed820
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 569 additions and 15 deletions

1
.gitignore vendored
View File

@ -26,6 +26,7 @@ var/
*.egg-info/ *.egg-info/
.installed.cfg .installed.cfg
*.egg *.egg
*.DS_Store
# Django stuff: # Django stuff:
*.log *.log

View File

@ -1,11 +1,14 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 161 INVENTREE_API_VERSION = 162
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v162 -> 2024-01-14 : https://github.com/inventree/InvenTree/pull/6230
- Adds API endpoints to provide information on background tasks
v161 -> 2024-01-13 : https://github.com/inventree/InvenTree/pull/6222 v161 -> 2024-01-13 : https://github.com/inventree/InvenTree/pull/6222
- Adds API endpoint for system error information - Adds API endpoint for system error information

View File

@ -138,12 +138,22 @@ class InvenTreeConfig(AppConfig):
Schedule.objects.bulk_update(tasks_to_update, ['schedule_type', 'minutes']) Schedule.objects.bulk_update(tasks_to_update, ['schedule_type', 'minutes'])
logger.info('Updated %s existing scheduled tasks', len(tasks_to_update)) logger.info('Updated %s existing scheduled tasks', len(tasks_to_update))
# Put at least one task onto the background worker stack, self.add_heartbeat()
# which will be processed as soon as the worker comes online
InvenTree.tasks.offload_task(InvenTree.tasks.heartbeat, force_async=True)
logger.info('Started %s scheduled background tasks...', len(tasks)) logger.info('Started %s scheduled background tasks...', len(tasks))
def add_heartbeat(self):
"""Ensure there is at least one background task in the queue."""
import django_q.models
try:
if django_q.models.OrmQ.objects.count() == 0:
InvenTree.tasks.offload_task(
InvenTree.tasks.heartbeat, force_async=True
)
except Exception:
pass
def collect_tasks(self): def collect_tasks(self):
"""Collect all background tasks.""" """Collect all background tasks."""
for app_name, app in apps.app_configs.items(): for app_name, app in apps.app_configs.items():

View File

@ -347,7 +347,7 @@ def heartbeat():
(There is probably a less "hacky" way of achieving this)? (There is probably a less "hacky" way of achieving this)?
""" """
try: try:
from django_q.models import Success from django_q.models import OrmQ, Success
except AppRegistryNotReady: # pragma: no cover except AppRegistryNotReady: # pragma: no cover
logger.info('Could not perform heartbeat task - App registry not ready') logger.info('Could not perform heartbeat task - App registry not ready')
return return
@ -362,6 +362,11 @@ def heartbeat():
heartbeats.delete() heartbeats.delete()
# Clear out any other pending heartbeat tasks
for task in OrmQ.objects.all():
if task.func() == 'InvenTree.tasks.heartbeat':
task.delete()
@scheduled_task(ScheduledTask.DAILY) @scheduled_task(ScheduledTask.DAILY)
def delete_successful_tasks(): def delete_successful_tasks():

View File

@ -8,6 +8,7 @@ from django.urls import include, path, re_path
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
import django_q.models
from django_q.tasks import async_task from django_q.tasks import async_task
from djmoney.contrib.exchange.models import ExchangeBackend, Rate from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from error_report.models import Error from error_report.models import Error
@ -509,6 +510,71 @@ class ErrorMessageDetail(RetrieveUpdateDestroyAPI):
permission_classes = [permissions.IsAuthenticated, IsAdminUser] permission_classes = [permissions.IsAuthenticated, IsAdminUser]
class BackgroundTaskOverview(APIView):
"""Provides an overview of the background task queue status."""
permission_classes = [permissions.IsAuthenticated, IsAdminUser]
def get(self, request, format=None):
"""Return information about the current status of the background task queue."""
import django_q.models as q_models
import InvenTree.status
serializer = common.serializers.TaskOverviewSerializer({
'is_running': InvenTree.status.is_worker_running(),
'pending_tasks': q_models.OrmQ.objects.count(),
'scheduled_tasks': q_models.Schedule.objects.count(),
'failed_tasks': q_models.Failure.objects.count(),
})
return Response(serializer.data)
class PendingTaskList(BulkDeleteMixin, ListAPI):
"""Provides a read-only list of currently pending tasks."""
permission_classes = [permissions.IsAuthenticated, IsAdminUser]
queryset = django_q.models.OrmQ.objects.all()
serializer_class = common.serializers.PendingTaskSerializer
class ScheduledTaskList(ListAPI):
"""Provides a read-only list of currently scheduled tasks."""
permission_classes = [permissions.IsAuthenticated, IsAdminUser]
queryset = django_q.models.Schedule.objects.all()
serializer_class = common.serializers.ScheduledTaskSerializer
filter_backends = SEARCH_ORDER_FILTER
ordering_fields = ['pk', 'func', 'last_run', 'next_run']
search_fields = ['func']
def get_queryset(self):
"""Return annotated queryset."""
queryset = super().get_queryset()
return common.serializers.ScheduledTaskSerializer.annotate_queryset(queryset)
class FailedTaskList(BulkDeleteMixin, ListAPI):
"""Provides a read-only list of currently failed tasks."""
permission_classes = [permissions.IsAuthenticated, IsAdminUser]
queryset = django_q.models.Failure.objects.all()
serializer_class = common.serializers.FailedTaskSerializer
filter_backends = SEARCH_ORDER_FILTER
ordering_fields = ['pk', 'func', 'started', 'stopped']
search_fields = ['func']
class FlagList(ListAPI): class FlagList(ListAPI):
"""List view for feature flags.""" """List view for feature flags."""
@ -590,6 +656,24 @@ common_api_urls = [
re_path( re_path(
r'^notes-image-upload/', NotesImageList.as_view(), name='api-notes-image-list' r'^notes-image-upload/', NotesImageList.as_view(), name='api-notes-image-list'
), ),
# Background task information
re_path(
r'^background-task/',
include([
re_path(
r'^pending/', PendingTaskList.as_view(), name='api-pending-task-list'
),
re_path(
r'^scheduled/',
ScheduledTaskList.as_view(),
name='api-scheduled-task-list',
),
re_path(r'^failed/', FailedTaskList.as_view(), name='api-failed-task-list'),
re_path(
r'^.*$', BackgroundTaskOverview.as_view(), name='api-task-overview'
),
]),
),
# Project codes # Project codes
re_path( re_path(
r'^project-code/', r'^project-code/',

View File

@ -1,7 +1,10 @@
"""JSON serializers for common components.""" """JSON serializers for common components."""
from django.db.models import OuterRef, Subquery
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _
import django_q.models
from error_report.models import Error from error_report.models import Error
from flags.state import flag_state from flags.state import flag_state
from rest_framework import serializers from rest_framework import serializers
@ -316,3 +319,120 @@ class ErrorMessageSerializer(InvenTreeModelSerializer):
fields = ['when', 'info', 'data', 'path', 'pk'] fields = ['when', 'info', 'data', 'path', 'pk']
read_only_fields = ['when', 'info', 'data', 'path', 'pk'] read_only_fields = ['when', 'info', 'data', 'path', 'pk']
class TaskOverviewSerializer(serializers.Serializer):
"""Serializer for background task overview."""
is_running = serializers.BooleanField(
label=_('Is Running'),
help_text='Boolean value to indicate if the background worker process is running.',
read_only=True,
)
pending_tasks = serializers.IntegerField(
label=_('Pending Tasks'),
help_text='Number of active background tasks',
read_only=True,
)
scheduled_tasks = serializers.IntegerField(
label=_('Scheduled Tasks'),
help_text='Number of scheduled background tasks',
read_only=True,
)
failed_tasks = serializers.IntegerField(
label=_('Failed Tasks'),
help_text='Number of failed background tasks',
read_only=True,
)
class PendingTaskSerializer(InvenTreeModelSerializer):
"""Serializer for an individual pending task object."""
class Meta:
"""Metaclass options for the serializer."""
model = django_q.models.OrmQ
fields = ['pk', 'key', 'lock', 'task_id', 'name', 'func', 'args', 'kwargs']
task_id = serializers.CharField(label=_('Task ID'), help_text=_('Unique task ID'))
lock = serializers.DateTimeField(label=_('Lock'), help_text=_('Lock time'))
name = serializers.CharField(label=_('Name'), help_text=_('Task name'))
func = serializers.CharField(label=_('Function'), help_text=_('Function name'))
args = serializers.CharField(label=_('Arguments'), help_text=_('Task arguments'))
kwargs = serializers.CharField(
label=_('Keyword Arguments'), help_text=_('Task keyword arguments')
)
class ScheduledTaskSerializer(InvenTreeModelSerializer):
"""Serializer for an individual scheduled task object."""
class Meta:
"""Metaclass options for the serializer."""
model = django_q.models.Schedule
fields = [
'pk',
'name',
'func',
'args',
'kwargs',
'schedule_type',
'repeats',
'last_run',
'next_run',
'success',
'task',
]
last_run = serializers.DateTimeField()
success = serializers.BooleanField()
@staticmethod
def annotate_queryset(queryset):
"""Add custom annotations to the queryset.
- last_run: The last time the task was run
- success: The outcome status of the last run
"""
task = django_q.models.Task.objects.filter(id=OuterRef('task'))
queryset = queryset.annotate(
last_run=Subquery(task.values('started')[:1]),
success=Subquery(task.values('success')[:1]),
)
return queryset
class FailedTaskSerializer(InvenTreeModelSerializer):
"""Serializer for an individual failed task object."""
class Meta:
"""Metaclass options for the serializer."""
model = django_q.models.Failure
fields = [
'pk',
'name',
'func',
'args',
'kwargs',
'started',
'stopped',
'attempt_count',
'result',
]
pk = serializers.CharField(source='id', read_only=True)
result = serializers.CharField()

View File

@ -658,6 +658,50 @@ class PluginSettingsApiTest(PluginMixin, InvenTreeAPITestCase):
... ...
class TaskListApiTests(InvenTreeAPITestCase):
"""Unit tests for the background task API endpoints."""
def test_pending_tasks(self):
"""Test that the pending tasks endpoint is available."""
# Schedule some tasks
from django_q.models import OrmQ
from InvenTree.tasks import offload_task
n = OrmQ.objects.count()
for i in range(3):
offload_task(f'fake_module.test_{i}', force_async=True)
self.assertEqual(OrmQ.objects.count(), 3)
url = reverse('api-pending-task-list')
response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), n + 3)
for task in response.data:
self.assertTrue(task['func'].startswith('fake_module.test_'))
def test_scheduled_tasks(self):
"""Test that the scheduled tasks endpoint is available."""
from django_q.models import Schedule
for i in range(5):
Schedule.objects.create(
name='time.sleep', func='time.sleep', args=f'{i + 1}'
)
n = Schedule.objects.count()
self.assertGreater(n, 0)
url = reverse('api-scheduled-task-list')
response = self.get(url, expected_code=200)
for task in response.data:
self.assertTrue(task['name'] == 'time.sleep')
class WebhookMessageTests(TestCase): class WebhookMessageTests(TestCase):
"""Tests for webhooks.""" """Tests for webhooks."""

View File

@ -54,9 +54,7 @@ class CompanyList(ListCreateAPI):
def get_queryset(self): def get_queryset(self):
"""Return annotated queryset for the company list endpoint.""" """Return annotated queryset for the company list endpoint."""
queryset = super().get_queryset() queryset = super().get_queryset()
queryset = CompanySerializer.annotate_queryset(queryset) return CompanySerializer.annotate_queryset(queryset)
return queryset
filter_backends = SEARCH_ORDER_FILTER filter_backends = SEARCH_ORDER_FILTER

View File

@ -388,7 +388,9 @@ export function InvenTreeTable<T = any>({
}, },
onConfirm: () => { onConfirm: () => {
// Delete the selected records // Delete the selected records
let selection = tableState.selectedRecords.map((record) => record.pk); let selection = tableState.selectedRecords.map(
(record) => record.pk ?? record.id
);
api api
.delete(url, { .delete(url, {
@ -409,6 +411,12 @@ export function InvenTreeTable<T = any>({
}) })
.catch((_error) => { .catch((_error) => {
console.warn(`Bulk delete operation failed at ${url}`); console.warn(`Bulk delete operation failed at ${url}`);
showNotification({
title: t`Error`,
message: t`Failed to delete records`,
color: 'red'
});
}); });
} }
}); });

View File

@ -80,7 +80,6 @@ export default function ErrorReportTable() {
enableSelection: true, enableSelection: true,
rowActions: rowActions, rowActions: rowActions,
onRowClick: (row) => { onRowClick: (row) => {
console.log(row);
setError(row.data); setError(row.data);
open(); open();
} }

View File

@ -0,0 +1,79 @@
import { t } from '@lingui/macro';
import { Drawer, Text } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useMemo, useState } from 'react';
import { ApiPaths } from '../../../enums/ApiEndpoints';
import { useTable } from '../../../hooks/UseTable';
import { apiUrl } from '../../../states/ApiState';
import { StylishText } from '../../items/StylishText';
import { TableColumn } from '../Column';
import { InvenTreeTable } from '../InvenTreeTable';
export default function FailedTasksTable() {
const table = useTable('tasks-failed');
const [error, setError] = useState<string>('');
const [opened, { open, close }] = useDisclosure(false);
const columns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'func',
title: t`Task`,
sortable: true,
switchable: false
},
{
accessor: 'pk',
title: t`Task ID`
},
{
accessor: 'started',
title: t`Started`,
sortable: true,
switchable: false
},
{
accessor: 'stopped',
title: t`Stopped`,
sortable: true,
switchable: false
},
{
accessor: 'attempt_count',
title: t`Attempts`
}
];
}, []);
return (
<>
<Drawer
opened={opened}
size="xl"
position="right"
title={<StylishText>{t`Error Details`}</StylishText>}
onClose={close}
>
{error.split('\n').map((line: string) => {
return <Text size="sm">{line}</Text>;
})}
</Drawer>
<InvenTreeTable
url={apiUrl(ApiPaths.task_failed_list)}
tableState={table}
columns={columns}
props={{
enableBulkDelete: true,
enableSelection: true,
onRowClick: (row: any) => {
setError(row.result);
open();
}
}}
/>
</>
);
}

View File

@ -0,0 +1,56 @@
import { t } from '@lingui/macro';
import { useMemo } from 'react';
import { ApiPaths } from '../../../enums/ApiEndpoints';
import { useTable } from '../../../hooks/UseTable';
import { apiUrl } from '../../../states/ApiState';
import { TableColumn } from '../Column';
import { InvenTreeTable } from '../InvenTreeTable';
export default function PendingTasksTable() {
const table = useTable('tasks-pending');
const columns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'func',
title: t`Task`,
switchable: false
},
{
accessor: 'task_id',
title: t`Task ID`
},
{
accessor: 'name',
title: t`Name`
},
{
accessor: 'lock',
title: t`Created`,
sortable: true,
switchable: false
},
{
accessor: 'args',
title: t`Arguments`
},
{
accessor: 'kwargs',
title: t`Keywords`
}
];
}, []);
return (
<InvenTreeTable
url={apiUrl(ApiPaths.task_pending_list)}
tableState={table}
columns={columns}
props={{
enableBulkDelete: true,
enableSelection: true
}}
/>
);
}

View File

@ -0,0 +1,62 @@
import { t } from '@lingui/macro';
import { Group, Text } from '@mantine/core';
import { IconCircleCheck, IconCircleX } from '@tabler/icons-react';
import { useMemo } from 'react';
import { ApiPaths } from '../../../enums/ApiEndpoints';
import { useTable } from '../../../hooks/UseTable';
import { apiUrl } from '../../../states/ApiState';
import { TableColumn } from '../Column';
import { InvenTreeTable } from '../InvenTreeTable';
export default function ScheduledTasksTable() {
const table = useTable('tasks-scheduled');
const columns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'func',
title: t`Task`,
sortable: true,
switchable: false
},
{
accessor: 'last_run',
title: t`Last Run`,
sortable: true,
switchable: false,
render: (record: any) => {
if (!record.last_run) {
return '-';
}
return (
<Group position="apart">
<Text>{record.last_run}</Text>
{record.success ? (
<IconCircleCheck color="green" />
) : (
<IconCircleX color="red" />
)}
</Group>
);
}
},
{
accessor: 'next_run',
title: t`Next Run`,
sortable: true,
switchable: false
}
];
}, []);
return (
<InvenTreeTable
url={apiUrl(ApiPaths.task_scheduled_list)}
tableState={table}
columns={columns}
props={{}}
/>
);
}

View File

@ -24,6 +24,11 @@ export enum ApiPaths {
group_list = 'api-group-list', group_list = 'api-group-list',
owner_list = 'api-owner-list', owner_list = 'api-owner-list',
task_overview = 'api-task-overview',
task_pending_list = 'api-task-pending-list',
task_scheduled_list = 'api-task-scheduled-list',
task_failed_list = 'api-task-failed-list',
settings_global_list = 'api-settings-global-list', settings_global_list = 'api-settings-global-list',
settings_user_list = 'api-settings-user-list', settings_user_list = 'api-settings-user-list',
notifications_list = 'api-notifications-list', notifications_list = 'api-notifications-list',

View File

@ -1,6 +1,7 @@
import { Trans, t } from '@lingui/macro'; import { Trans, t } from '@lingui/macro';
import { Divider, Paper, SimpleGrid, Stack, Text, Title } from '@mantine/core'; import { Divider, Paper, SimpleGrid, Stack, Text, Title } from '@mantine/core';
import { import {
IconCpu,
IconExclamationCircle, IconExclamationCircle,
IconList, IconList,
IconListDetails, IconListDetails,
@ -20,6 +21,10 @@ const UserManagementPanel = Loadable(
lazy(() => import('./UserManagementPanel')) lazy(() => import('./UserManagementPanel'))
); );
const TaskManagementPanel = Loadable(
lazy(() => import('./TaskManagementPanel'))
);
const PluginManagementPanel = Loadable( const PluginManagementPanel = Loadable(
lazy(() => import('./PluginManagementPanel')) lazy(() => import('./PluginManagementPanel'))
); );
@ -52,6 +57,12 @@ export default function AdminCenter() {
icon: <IconUsersGroup />, icon: <IconUsersGroup />,
content: <UserManagementPanel /> content: <UserManagementPanel />
}, },
{
name: 'background',
label: t`Background Tasks`,
icon: <IconCpu />,
content: <TaskManagementPanel />
},
{ {
name: 'errors', name: 'errors',
label: t`Error Reports`, label: t`Error Reports`,
@ -126,7 +137,7 @@ export default function AdminCenter() {
<PanelGroup <PanelGroup
pageKey="admin-center" pageKey="admin-center"
panels={adminCenterPanels} panels={adminCenterPanels}
collapsible={false} collapsible={true}
/> />
</Stack> </Stack>
); );

View File

@ -0,0 +1,51 @@
import { t } from '@lingui/macro';
import { Accordion } from '@mantine/core';
import { lazy } from 'react';
import { StylishText } from '../../../../components/items/StylishText';
import { Loadable } from '../../../../functions/loading';
const PendingTasksTable = Loadable(
lazy(() => import('../../../../components/tables/settings/PendingTasksTable'))
);
const ScheduledTasksTable = Loadable(
lazy(
() => import('../../../../components/tables/settings/ScheduledTasksTable')
)
);
const FailedTasksTable = Loadable(
lazy(() => import('../../../../components/tables/settings/FailedTasksTable'))
);
export default function TaskManagementPanel() {
return (
<Accordion defaultValue="pending">
<Accordion.Item value="pending">
<Accordion.Control>
<StylishText size="lg">{t`Pending Tasks`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<PendingTasksTable />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="scheduled">
<Accordion.Control>
<StylishText size="lg">{t`Scheduled Tasks`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<ScheduledTasksTable />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="failed">
<Accordion.Control>
<StylishText size="lg">{t`Failed Tasks`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<FailedTasksTable />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}

View File

@ -99,6 +99,14 @@ export function apiEndpoint(path: ApiPaths): string {
return 'currency/exchange/'; return 'currency/exchange/';
case ApiPaths.currency_refresh: case ApiPaths.currency_refresh:
return 'currency/refresh/'; return 'currency/refresh/';
case ApiPaths.task_overview:
return 'background-task/';
case ApiPaths.task_pending_list:
return 'background-task/pending/';
case ApiPaths.task_scheduled_list:
return 'background-task/scheduled/';
case ApiPaths.task_failed_list:
return 'background-task/failed/';
case ApiPaths.api_search: case ApiPaths.api_search:
return 'search/'; return 'search/';
case ApiPaths.settings_global_list: case ApiPaths.settings_global_list:

View File

@ -376,14 +376,21 @@ def migrate(c):
@task( @task(
post=[static, clean_settings, translate_stats], post=[clean_settings, translate_stats],
help={ help={
'skip_backup': 'Skip database backup step (advanced users)', 'skip_backup': 'Skip database backup step (advanced users)',
'frontend': 'Force frontend compilation/download step (ignores INVENTREE_DOCKER)', 'frontend': 'Force frontend compilation/download step (ignores INVENTREE_DOCKER)',
'no_frontend': 'Skip frontend compilation/download step', 'no_frontend': 'Skip frontend compilation/download step',
'skip_static': 'Skip static file collection step',
}, },
) )
def update(c, skip_backup=False, frontend: bool = False, no_frontend: bool = False): def update(
c,
skip_backup: bool = False,
frontend: bool = False,
no_frontend: bool = False,
skip_static: bool = False,
):
"""Update InvenTree installation. """Update InvenTree installation.
This command should be invoked after source code has been updated, This command should be invoked after source code has been updated,
@ -394,8 +401,8 @@ def update(c, skip_backup=False, frontend: bool = False, no_frontend: bool = Fal
- install - install
- backup (optional) - backup (optional)
- migrate - migrate
- frontend_compile or frontend_download - frontend_compile or frontend_download (optional)
- static - static (optional)
- clean_settings - clean_settings
- translate_stats - translate_stats
""" """
@ -421,6 +428,9 @@ def update(c, skip_backup=False, frontend: bool = False, no_frontend: bool = Fal
else: else:
frontend_download(c) frontend_download(c)
if not skip_static:
static(c)
# Data tasks # Data tasks
@task( @task(