2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-14 19:15:41 +00:00

[plugin] Auto issue orders (#9565)

* Add builtin plugin for auto-issuing orders

* Add plugin to auto-issue orders

* Add placeholder documentation

* Fix typo

* Adds image macro

- To replace img.html
- includes checking if file exists

* Fix tooltips

* More docs

* Adjust plugin settings filters

* docs

* More docs

* More docs

* Updates

* Less restrictive URL checking

* Refactor build order page

* Fix typo

* Allow 429

* Debug output

* More debug

* Construct assets dir

* Cleanup

* Update docs README

* Refactoring more pages

* Fix image link

* Fix SSO settings

* Add hook to check for missing settings

- Ensure that all settings are documented!

* Add missing user settings

* Update docstring

* Tweak SSO.md

* Image updates

* More updates

* Tweaks

* Exclude orders without a target_date

* Fix for issuing build orders

* Further refactoring

* Fixes

* Image refactoring

* More refactoring

* More refactoring

* Refactor app images

* Fix pathing issues

* Suppress some openapidocs warnings in logs

(much easier to debug docs build issues)

* Fix image reference

* Reduce error messages

* Fix image links

* Fix image links

* Reduce docs log output

* Ensure settings are loaded before displaying them

* Fix for UI test

* Fix unit test

* Test tweaks
This commit is contained in:
Oliver
2025-06-03 17:07:12 +10:00
committed by GitHub
parent 89f8f132e1
commit 11ab0203b1
124 changed files with 1178 additions and 957 deletions

View File

@ -461,12 +461,6 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False,
'validator': bool,
},
'PART_SHOW_IMPORT': {
'name': _('Show Import in Views'),
'description': _('Display the import wizard in some part views'),
'default': False,
'validator': bool,
},
'PART_SHOW_RELATED': {
'name': _('Show related parts'),
'description': _('Display related parts for a part'),

View File

@ -0,0 +1,130 @@
"""Plugin to automatically issue orders on the assigned target date."""
from django.utils.translation import gettext_lazy as _
import structlog
from InvenTree.helpers import current_date
from plugin import InvenTreePlugin
from plugin.mixins import ScheduleMixin, SettingsMixin
logger = structlog.get_logger('inventree')
class AutoIssueOrdersPlugin(ScheduleMixin, SettingsMixin, InvenTreePlugin):
"""Plugin to automatically issue orders on the assigned target date."""
NAME = _('Auto Issue Orders')
SLUG = 'autoiissueorders'
AUTHOR = _('InvenTree contributors')
DESCRIPTION = _('Automatically issue orders on the assigned target date')
VERSION = '1.0.0'
# Set the scheduled tasks for this plugin
SCHEDULED_TASKS = {
'auto_issue_orders': {'func': 'auto_issue_orders', 'schedule': 'D'}
}
SETTINGS = {
'AUTO_ISSUE_BUILD_ORDERS': {
'name': _('Auto Issue Build Orders'),
'description': _(
'Automatically issue build orders on the assigned target date'
),
'validator': bool,
'default': True,
},
'AUTO_ISSUE_PURCHASE_ORDERS': {
'name': _('Auto Issue Purchase Orders'),
'description': _(
'Automatically issue purchase orders on the assigned target date'
),
'validator': bool,
'default': True,
},
'AUTO_ISSUE_SALES_ORDERS': {
'name': _('Auto Issue Sales Orders'),
'description': _(
'Automatically issue sales orders on the assigned target date'
),
'validator': bool,
'default': True,
},
'AUTO_ISSUE_RETURN_ORDERS': {
'name': _('Auto Issue Return Orders'),
'description': _(
'Automatically issue return orders on the assigned target date'
),
'validator': bool,
'default': True,
},
'ISSUE_BACKDATED_ORDERS': {
'name': _('Issue Backdated Orders'),
'description': _('Automatically issue orders that are backdated'),
'validator': bool,
'default': False,
},
}
def auto_issue_orders(self):
"""Automatically issue orders on the assigned target date."""
if self.get_setting('AUTO_ISSUE_BUILD_ORDERS', backup_value=True):
self.auto_issue_build_orders()
if self.get_setting('AUTO_ISSUE_PURCHASE_ORDERS', backup_value=True):
self.auto_issue_purchase_orders()
if self.get_setting('AUTO_ISSUE_SALES_ORDERS', backup_value=True):
self.auto_issue_sales_orders()
if self.get_setting('AUTO_ISSUE_RETURN_ORDERS', backup_value=True):
self.auto_issue_return_orders()
def issue_func(self, model, status: int, func_name: str = 'issue_order'):
"""Helper function to issue orders of a given model and status."""
orders = model.objects.filter(status=status)
orders = orders.filter(target_date__isnull=False)
if self.get_setting('ISSUE_BACKDATED_ORDERS', backup_value=False):
orders = orders.filter(target_date__lte=current_date())
else:
orders = orders.filter(target_date=current_date())
if orders.count() == 0:
return
logger.info('Auto-issuing %d orders for %s', orders.count(), model.__name__)
for order in orders:
try:
getattr(order, func_name)()
except Exception as e:
logger.error('Failed to issue order %s: %s', order.pk, str(e))
def auto_issue_build_orders(self):
"""Automatically issue build orders on the assigned target date."""
from build.models import Build
from build.status_codes import BuildStatus
self.issue_func(Build, BuildStatus.PENDING, func_name='issue_build')
def auto_issue_purchase_orders(self):
"""Automatically issue purchase orders on the assigned target date."""
from order.models import PurchaseOrder
from order.status_codes import PurchaseOrderStatus
self.issue_func(PurchaseOrder, PurchaseOrderStatus.PENDING)
def auto_issue_sales_orders(self):
"""Automatically issue sales orders on the assigned target date."""
from order.models import SalesOrder
from order.status_codes import SalesOrderStatus
self.issue_func(SalesOrder, SalesOrderStatus.PENDING)
def auto_issue_return_orders(self):
"""Automatically issue return orders on the assigned target date."""
from order.models import ReturnOrder
from order.status_codes import ReturnOrderStatus
self.issue_func(ReturnOrder, ReturnOrderStatus.PENDING)

View File

@ -15,7 +15,7 @@ class DigiKeyPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin):
NAME = 'DigiKeyPlugin'
TITLE = _('Supplier Integration - DigiKey')
DESCRIPTION = _('Provides support for scanning DigiKey barcodes')
VERSION = '1.0.0'
VERSION = '1.0.1'
AUTHOR = _('InvenTree contributors')
DEFAULT_SUPPLIER_NAME = 'DigiKey'
@ -25,6 +25,7 @@ class DigiKeyPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin):
'name': _('Supplier'),
'description': _("The Supplier which acts as 'DigiKey'"),
'model': 'company.company',
'model_filters': {'is_supplier': True},
}
}

View File

@ -17,7 +17,7 @@ class LCSCPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin):
NAME = 'LCSCPlugin'
TITLE = _('Supplier Integration - LCSC')
DESCRIPTION = _('Provides support for scanning LCSC barcodes')
VERSION = '1.0.0'
VERSION = '1.0.1'
AUTHOR = _('InvenTree contributors')
DEFAULT_SUPPLIER_NAME = 'LCSC'
@ -26,6 +26,7 @@ class LCSCPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin):
'name': _('Supplier'),
'description': _("The Supplier which acts as 'LCSC'"),
'model': 'company.company',
'model_filters': {'is_supplier': True},
}
}

View File

@ -15,7 +15,7 @@ class MouserPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin):
NAME = 'MouserPlugin'
TITLE = _('Supplier Integration - Mouser')
DESCRIPTION = _('Provides support for scanning Mouser barcodes')
VERSION = '1.0.0'
VERSION = '1.0.1'
AUTHOR = _('InvenTree contributors')
DEFAULT_SUPPLIER_NAME = 'Mouser'
@ -24,6 +24,7 @@ class MouserPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin):
'name': _('Supplier'),
'description': _("The Supplier which acts as 'Mouser'"),
'model': 'company.company',
'model_filters': {'is_supplier': True},
}
}

View File

@ -17,7 +17,7 @@ class TMEPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin):
NAME = 'TMEPlugin'
TITLE = _('Supplier Integration - TME')
DESCRIPTION = _('Provides support for scanning TME barcodes')
VERSION = '1.0.0'
VERSION = '1.0.1'
AUTHOR = _('InvenTree contributors')
DEFAULT_SUPPLIER_NAME = 'TME'
@ -26,6 +26,7 @@ class TMEPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin):
'name': _('Supplier'),
'description': _("The Supplier which acts as 'TME'"),
'model': 'company.company',
'model_filters': {'is_supplier': True},
}
}

View File

@ -48,6 +48,7 @@ export interface SettingsStateProps {
settings: Setting[];
lookup: SettingsLookup;
fetchSettings: () => Promise<boolean>;
loaded: boolean;
endpoint: ApiEndpoints;
pathParams?: PathParams;
getSetting: (key: string, default_value?: string) => string; // Return a raw setting value

View File

@ -1,6 +1,6 @@
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { Stack, Text } from '@mantine/core';
import { Skeleton, Stack, Text } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import React, {
useCallback,
@ -133,6 +133,10 @@ export function SettingList({
[settingsState]
);
if (!settingsState?.loaded) {
return <Skeleton animate />;
}
return (
<>
{editSettingModal.modal}

View File

@ -21,6 +21,7 @@ import { useUserState } from './UserState';
export const useGlobalSettingsState = create<SettingsStateProps>(
(set, get) => ({
settings: [],
loaded: false,
lookup: {},
endpoint: ApiEndpoints.settings_global_list,
fetchSettings: async () => {
@ -28,6 +29,9 @@ export const useGlobalSettingsState = create<SettingsStateProps>(
const { isLoggedIn } = useUserState.getState();
if (!isLoggedIn()) {
set({
loaded: false
});
return success;
}
@ -36,12 +40,17 @@ export const useGlobalSettingsState = create<SettingsStateProps>(
.then((response) => {
set({
settings: response.data,
loaded: true,
lookup: generate_lookup(response.data)
});
})
.catch((_error) => {
console.error('ERR: Error fetching global settings');
success = false;
set({
loaded: false
});
});
return success;
@ -62,12 +71,16 @@ export const useGlobalSettingsState = create<SettingsStateProps>(
export const useUserSettingsState = create<SettingsStateProps>((set, get) => ({
settings: [],
lookup: {},
loaded: false,
endpoint: ApiEndpoints.settings_user_list,
fetchSettings: async () => {
let success = true;
const { isLoggedIn } = useUserState.getState();
if (!isLoggedIn()) {
set({
loaded: false
});
return success;
}
@ -76,12 +89,16 @@ export const useUserSettingsState = create<SettingsStateProps>((set, get) => ({
.then((response) => {
set({
settings: response.data,
lookup: generate_lookup(response.data)
lookup: generate_lookup(response.data),
loaded: true
});
})
.catch((_error) => {
console.error('ERR: Error fetching user settings');
success = false;
set({
loaded: false
});
});
return success;
@ -110,6 +127,7 @@ export const createPluginSettingsState = ({
return createStore<SettingsStateProps>()((set, get) => ({
settings: [],
lookup: {},
loaded: false,
endpoint: ApiEndpoints.plugin_setting_list,
pathParams,
fetchSettings: async () => {
@ -121,12 +139,16 @@ export const createPluginSettingsState = ({
const settings = response.data;
set({
settings,
lookup: generate_lookup(settings)
lookup: generate_lookup(settings),
loaded: true
});
})
.catch((_error) => {
console.error(`Error fetching plugin settings for plugin ${plugin}`);
success = false;
set({
loaded: false
});
});
return success;
@ -158,6 +180,7 @@ export const createMachineSettingsState = ({
return createStore<SettingsStateProps>()((set, get) => ({
settings: [],
lookup: {},
loaded: false,
endpoint: ApiEndpoints.machine_setting_detail,
pathParams,
fetchSettings: async () => {
@ -171,7 +194,8 @@ export const createMachineSettingsState = ({
);
set({
settings,
lookup: generate_lookup(settings)
lookup: generate_lookup(settings),
loaded: true
});
})
.catch((error) => {
@ -180,6 +204,9 @@ export const createMachineSettingsState = ({
error
);
success = false;
set({
loaded: false
});
});
return success;

View File

@ -13,7 +13,7 @@ export function TableColumnSelect({
<Menu shadow='xs' closeOnItemClick={false}>
<Menu.Target>
<ActionIcon variant='transparent' aria-label='table-select-columns'>
<Tooltip label={t`Select Columns`}>
<Tooltip label={t`Select Columns`} position='top-end'>
<IconAdjustments />
</Tooltip>
</ActionIcon>

View File

@ -208,7 +208,7 @@ export default function InvenTreeTableHeader({
)}
{tableProps.enableRefresh && (
<ActionIcon variant='transparent' aria-label='table-refresh'>
<Tooltip label={t`Refresh data`}>
<Tooltip label={t`Refresh data`} position='top-end'>
<IconRefresh
onClick={() => {
tableState.refreshTable();
@ -235,7 +235,7 @@ export default function InvenTreeTableHeader({
variant='transparent'
aria-label='table-select-filters'
>
<Tooltip label={t`Table Filters`}>
<Tooltip label={t`Table Filters`} position='top-end'>
<IconFilter
onClick={() => setFiltersVisible(!filtersVisible)}
/>
@ -245,7 +245,7 @@ export default function InvenTreeTableHeader({
)}
{tableUrl && tableProps.enableDownload && (
<ActionIcon variant='transparent' aria-label='table-export-data'>
<Tooltip label={t`Download data`} position='bottom'>
<Tooltip label={t`Download data`} position='top-end'>
<IconDownload onClick={exportModal.open} />
</Tooltip>
</ActionIcon>

View File

@ -15,10 +15,13 @@ export default function ScheduledTasksTable() {
const columns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'func',
accessor: 'name',
title: t`Task`,
sortable: true,
switchable: false
switchable: false,
render: (record: any) => {
return record.name || record.task;
}
},
{
accessor: 'last_run',

View File

@ -128,11 +128,13 @@ test('Part - Editing', async ({ browser }) => {
// Test URL validation
await page.getByLabel('text-field-link').fill('htxp-??QQQ++');
await page.waitForTimeout(200);
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Enter a valid URL.').waitFor();
// Fill with an empty URL
await page.getByLabel('text-field-link').fill('');
await page.waitForTimeout(200);
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Item Updated').waitFor();
});

View File

@ -41,7 +41,10 @@ test('Plugins - Settings', async ({ browser, request }) => {
// Edit numerical value
await page.getByLabel('edit-setting-NUMERICAL_SETTING').click();
const originalValue = await page.getByLabel('number-field-value').innerText();
const originalValue = await page
.getByLabel('number-field-value')
.inputValue();
await page
.getByLabel('number-field-value')
.fill(originalValue == '999' ? '1000' : '999');

View File

@ -28,7 +28,7 @@ test('Label Printing', async ({ browser }) => {
// Select plugin
await page.getByLabel('related-field-plugin').click();
await page.getByText('InvenTreeLabelSheet').last().click();
await page.getByText('InvenTreeLabelMachine').last().click();
// Select label template
await page.getByLabel('related-field-template').click();
@ -37,7 +37,8 @@ test('Label Printing', async ({ browser }) => {
await page.waitForTimeout(100);
await page.getByLabel('related-field-plugin').click();
await page.getByText('InvenTreeLabelSheet').last().click();
await page.getByText('InvenTreeLabel', { exact: true }).click();
// Submit the print form (second time should result in success)
await page.getByRole('button', { name: 'Print', exact: true }).isEnabled();