mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +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