From 75f75ed8203be92a8aba145f343598ac21fcc8a0 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 17 Jan 2024 07:10:42 +1100 Subject: [PATCH] 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 --- .gitignore | 1 + InvenTree/InvenTree/api_version.py | 5 +- InvenTree/InvenTree/apps.py | 16 ++- InvenTree/InvenTree/tasks.py | 7 +- InvenTree/common/api.py | 84 ++++++++++++ InvenTree/common/serializers.py | 120 ++++++++++++++++++ InvenTree/common/tests.py | 44 +++++++ InvenTree/company/api.py | 4 +- .../src/components/tables/InvenTreeTable.tsx | 10 +- .../components/tables/settings/ErrorTable.tsx | 1 - .../tables/settings/FailedTasksTable.tsx | 79 ++++++++++++ .../tables/settings/PendingTasksTable.tsx | 56 ++++++++ .../tables/settings/ScheduledTasksTable.tsx | 62 +++++++++ src/frontend/src/enums/ApiEndpoints.tsx | 5 + .../Index/Settings/AdminCenter/Index.tsx | 13 +- .../AdminCenter/TaskManagementPanel.tsx | 51 ++++++++ src/frontend/src/states/ApiState.tsx | 8 ++ tasks.py | 18 ++- 18 files changed, 569 insertions(+), 15 deletions(-) create mode 100644 src/frontend/src/components/tables/settings/FailedTasksTable.tsx create mode 100644 src/frontend/src/components/tables/settings/PendingTasksTable.tsx create mode 100644 src/frontend/src/components/tables/settings/ScheduledTasksTable.tsx create mode 100644 src/frontend/src/pages/Index/Settings/AdminCenter/TaskManagementPanel.tsx diff --git a/.gitignore b/.gitignore index 2f8554f5fb..a79862f102 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ var/ *.egg-info/ .installed.cfg *.egg +*.DS_Store # Django stuff: *.log diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index d90e01972a..320b828695 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # 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.""" 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 - Adds API endpoint for system error information diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index 8e4d04177f..edb02dbea9 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -138,12 +138,22 @@ class InvenTreeConfig(AppConfig): Schedule.objects.bulk_update(tasks_to_update, ['schedule_type', 'minutes']) logger.info('Updated %s existing scheduled tasks', len(tasks_to_update)) - # Put at least one task onto the background worker stack, - # which will be processed as soon as the worker comes online - InvenTree.tasks.offload_task(InvenTree.tasks.heartbeat, force_async=True) + self.add_heartbeat() 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): """Collect all background tasks.""" for app_name, app in apps.app_configs.items(): diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index b31e6b4905..05731dc60e 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -347,7 +347,7 @@ def heartbeat(): (There is probably a less "hacky" way of achieving this)? """ try: - from django_q.models import Success + from django_q.models import OrmQ, Success except AppRegistryNotReady: # pragma: no cover logger.info('Could not perform heartbeat task - App registry not ready') return @@ -362,6 +362,11 @@ def heartbeat(): 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) def delete_successful_tasks(): diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py index 3e0e3d1701..83973d97de 100644 --- a/InvenTree/common/api.py +++ b/InvenTree/common/api.py @@ -8,6 +8,7 @@ from django.urls import include, path, re_path from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt +import django_q.models from django_q.tasks import async_task from djmoney.contrib.exchange.models import ExchangeBackend, Rate from error_report.models import Error @@ -509,6 +510,71 @@ class ErrorMessageDetail(RetrieveUpdateDestroyAPI): 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): """List view for feature flags.""" @@ -590,6 +656,24 @@ common_api_urls = [ re_path( 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 re_path( r'^project-code/', diff --git a/InvenTree/common/serializers.py b/InvenTree/common/serializers.py index fc00067b20..8b6dcb70c2 100644 --- a/InvenTree/common/serializers.py +++ b/InvenTree/common/serializers.py @@ -1,7 +1,10 @@ """JSON serializers for common components.""" +from django.db.models import OuterRef, Subquery from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +import django_q.models from error_report.models import Error from flags.state import flag_state from rest_framework import serializers @@ -316,3 +319,120 @@ class ErrorMessageSerializer(InvenTreeModelSerializer): 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() diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py index 0915af16ab..b9368e079b 100644 --- a/InvenTree/common/tests.py +++ b/InvenTree/common/tests.py @@ -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): """Tests for webhooks.""" diff --git a/InvenTree/company/api.py b/InvenTree/company/api.py index 2c8b7a258e..89abd76e32 100644 --- a/InvenTree/company/api.py +++ b/InvenTree/company/api.py @@ -54,9 +54,7 @@ class CompanyList(ListCreateAPI): def get_queryset(self): """Return annotated queryset for the company list endpoint.""" queryset = super().get_queryset() - queryset = CompanySerializer.annotate_queryset(queryset) - - return queryset + return CompanySerializer.annotate_queryset(queryset) filter_backends = SEARCH_ORDER_FILTER diff --git a/src/frontend/src/components/tables/InvenTreeTable.tsx b/src/frontend/src/components/tables/InvenTreeTable.tsx index 281cc99626..cf8684b311 100644 --- a/src/frontend/src/components/tables/InvenTreeTable.tsx +++ b/src/frontend/src/components/tables/InvenTreeTable.tsx @@ -388,7 +388,9 @@ export function InvenTreeTable({ }, onConfirm: () => { // Delete the selected records - let selection = tableState.selectedRecords.map((record) => record.pk); + let selection = tableState.selectedRecords.map( + (record) => record.pk ?? record.id + ); api .delete(url, { @@ -409,6 +411,12 @@ export function InvenTreeTable({ }) .catch((_error) => { console.warn(`Bulk delete operation failed at ${url}`); + + showNotification({ + title: t`Error`, + message: t`Failed to delete records`, + color: 'red' + }); }); } }); diff --git a/src/frontend/src/components/tables/settings/ErrorTable.tsx b/src/frontend/src/components/tables/settings/ErrorTable.tsx index c02522d0ea..b2cd9b8692 100644 --- a/src/frontend/src/components/tables/settings/ErrorTable.tsx +++ b/src/frontend/src/components/tables/settings/ErrorTable.tsx @@ -80,7 +80,6 @@ export default function ErrorReportTable() { enableSelection: true, rowActions: rowActions, onRowClick: (row) => { - console.log(row); setError(row.data); open(); } diff --git a/src/frontend/src/components/tables/settings/FailedTasksTable.tsx b/src/frontend/src/components/tables/settings/FailedTasksTable.tsx new file mode 100644 index 0000000000..73125b75ce --- /dev/null +++ b/src/frontend/src/components/tables/settings/FailedTasksTable.tsx @@ -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(''); + + 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 ( + <> + {t`Error Details`}} + onClose={close} + > + {error.split('\n').map((line: string) => { + return {line}; + })} + + { + setError(row.result); + open(); + } + }} + /> + + ); +} diff --git a/src/frontend/src/components/tables/settings/PendingTasksTable.tsx b/src/frontend/src/components/tables/settings/PendingTasksTable.tsx new file mode 100644 index 0000000000..06dbec820b --- /dev/null +++ b/src/frontend/src/components/tables/settings/PendingTasksTable.tsx @@ -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 ( + + ); +} diff --git a/src/frontend/src/components/tables/settings/ScheduledTasksTable.tsx b/src/frontend/src/components/tables/settings/ScheduledTasksTable.tsx new file mode 100644 index 0000000000..685c8f66fd --- /dev/null +++ b/src/frontend/src/components/tables/settings/ScheduledTasksTable.tsx @@ -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 ( + + {record.last_run} + {record.success ? ( + + ) : ( + + )} + + ); + } + }, + { + accessor: 'next_run', + title: t`Next Run`, + sortable: true, + switchable: false + } + ]; + }, []); + + return ( + + ); +} diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index d12573573a..05a34f7d56 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -24,6 +24,11 @@ export enum ApiPaths { group_list = 'api-group-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_user_list = 'api-settings-user-list', notifications_list = 'api-notifications-list', diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx index a672650c43..3aa646ee22 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx @@ -1,6 +1,7 @@ import { Trans, t } from '@lingui/macro'; import { Divider, Paper, SimpleGrid, Stack, Text, Title } from '@mantine/core'; import { + IconCpu, IconExclamationCircle, IconList, IconListDetails, @@ -20,6 +21,10 @@ const UserManagementPanel = Loadable( lazy(() => import('./UserManagementPanel')) ); +const TaskManagementPanel = Loadable( + lazy(() => import('./TaskManagementPanel')) +); + const PluginManagementPanel = Loadable( lazy(() => import('./PluginManagementPanel')) ); @@ -52,6 +57,12 @@ export default function AdminCenter() { icon: , content: }, + { + name: 'background', + label: t`Background Tasks`, + icon: , + content: + }, { name: 'errors', label: t`Error Reports`, @@ -126,7 +137,7 @@ export default function AdminCenter() { ); diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/TaskManagementPanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/TaskManagementPanel.tsx new file mode 100644 index 0000000000..d0166e4d23 --- /dev/null +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/TaskManagementPanel.tsx @@ -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 ( + + + + {t`Pending Tasks`} + + + + + + + + {t`Scheduled Tasks`} + + + + + + + + {t`Failed Tasks`} + + + + + + + ); +} diff --git a/src/frontend/src/states/ApiState.tsx b/src/frontend/src/states/ApiState.tsx index 9ef5bf9c58..a7f2daab03 100644 --- a/src/frontend/src/states/ApiState.tsx +++ b/src/frontend/src/states/ApiState.tsx @@ -99,6 +99,14 @@ export function apiEndpoint(path: ApiPaths): string { return 'currency/exchange/'; case ApiPaths.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: return 'search/'; case ApiPaths.settings_global_list: diff --git a/tasks.py b/tasks.py index e1cfd1b67e..16489760fa 100644 --- a/tasks.py +++ b/tasks.py @@ -376,14 +376,21 @@ def migrate(c): @task( - post=[static, clean_settings, translate_stats], + post=[clean_settings, translate_stats], help={ 'skip_backup': 'Skip database backup step (advanced users)', 'frontend': 'Force frontend compilation/download step (ignores INVENTREE_DOCKER)', '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. 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 - backup (optional) - migrate - - frontend_compile or frontend_download - - static + - frontend_compile or frontend_download (optional) + - static (optional) - clean_settings - translate_stats """ @@ -421,6 +428,9 @@ def update(c, skip_backup=False, frontend: bool = False, no_frontend: bool = Fal else: frontend_download(c) + if not skip_static: + static(c) + # Data tasks @task(