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:
@ -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'),
|
||||
|
130
src/backend/InvenTree/plugin/builtin/events/auto_issue_orders.py
Normal file
130
src/backend/InvenTree/plugin/builtin/events/auto_issue_orders.py
Normal 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)
|
@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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}
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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',
|
||||
|
@ -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();
|
||||
});
|
||||
|
@ -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');
|
||||
|
@ -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();
|
||||
|
Reference in New Issue
Block a user