2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 19:46:46 +00:00

Fix for locate item plugins (#8473)

* A hook for caching active plugins

* Add LocateItemButton

* Implement "locate" button for location detail page too

* Fix for StockApiMixin.get_serializer

- Recent refactoring removed 'path_detail' attribute

* Fix offloading of 'locate'

* Remove debug msg

* Add custom message

* Remove force_async call

* Add playwright tests
This commit is contained in:
Oliver 2024-11-13 06:49:48 +11:00 committed by GitHub
parent 7b50f3b1d3
commit 246f17113f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 204 additions and 27 deletions

View File

@ -6,7 +6,7 @@ from rest_framework.generics import GenericAPIView
from rest_framework.response import Response from rest_framework.response import Response
from InvenTree.tasks import offload_task from InvenTree.tasks import offload_task
from plugin.registry import registry from plugin.registry import call_plugin_function, registry
from stock.models import StockItem, StockLocation from stock.models import StockItem, StockLocation
@ -59,7 +59,7 @@ class LocatePluginView(GenericAPIView):
StockItem.objects.get(pk=item_pk) StockItem.objects.get(pk=item_pk)
offload_task( offload_task(
registry.call_plugin_function, call_plugin_function,
plugin, plugin,
'locate_stock_item', 'locate_stock_item',
item_pk, item_pk,
@ -78,7 +78,7 @@ class LocatePluginView(GenericAPIView):
StockLocation.objects.get(pk=location_pk) StockLocation.objects.get(pk=location_pk)
offload_task( offload_task(
registry.call_plugin_function, call_plugin_function,
plugin, plugin,
'locate_stock_location', 'locate_stock_location',
location_pk, location_pk,

View File

@ -159,7 +159,7 @@ class PluginsRegistry:
# Update the registry hash value # Update the registry hash value
self.update_plugin_hash() self.update_plugin_hash()
def call_plugin_function(self, slug, func, *args, **kwargs): def call_plugin_function(self, slug: str, func: str, *args, **kwargs):
"""Call a member function (named by 'func') of the plugin named by 'slug'. """Call a member function (named by 'func') of the plugin named by 'slug'.
As this is intended to be run by the background worker, As this is intended to be run by the background worker,
@ -807,7 +807,7 @@ class PluginsRegistry:
registry: PluginsRegistry = PluginsRegistry() registry: PluginsRegistry = PluginsRegistry()
def call_function(plugin_name, function_name, *args, **kwargs): def call_plugin_function(plugin_name: str, function_name: str, *args, **kwargs):
"""Global helper function to call a specific member function of a plugin.""" """Global helper function to call a specific member function of a plugin."""
return registry.call_plugin_function(plugin_name, function_name, *args, **kwargs) return registry.call_plugin_function(plugin_name, function_name, *args, **kwargs)

View File

@ -5,7 +5,7 @@ from django.test import TestCase
from plugin import InvenTreePlugin, registry from plugin import InvenTreePlugin, registry
from plugin.helpers import MixinImplementationError from plugin.helpers import MixinImplementationError
from plugin.mixins import ScheduleMixin from plugin.mixins import ScheduleMixin
from plugin.registry import call_function from plugin.registry import call_plugin_function
class ExampleScheduledTaskPluginTests(TestCase): class ExampleScheduledTaskPluginTests(TestCase):
@ -67,10 +67,10 @@ class ExampleScheduledTaskPluginTests(TestCase):
def test_calling(self): def test_calling(self):
"""Check if a function can be called without errors.""" """Check if a function can be called without errors."""
# Check with right parameters # Check with right parameters
self.assertEqual(call_function('schedule', 'member_func'), False) self.assertEqual(call_plugin_function('schedule', 'member_func'), False)
# Check with wrong key # Check with wrong key
self.assertEqual(call_function('does_not_exist', 'member_func'), None) self.assertEqual(call_plugin_function('does_not_exist', 'member_func'), None)
class ScheduledTaskPluginTests(TestCase): class ScheduledTaskPluginTests(TestCase):

View File

@ -880,6 +880,7 @@ class StockApiMixin:
for key in [ for key in [
'part_detail', 'part_detail',
'path_detail',
'location_detail', 'location_detail',
'supplier_part_detail', 'supplier_part_detail',
'tests', 'tests',

View File

@ -11,7 +11,7 @@ import { ActionButton } from './ActionButton';
export type AdminButtonProps = { export type AdminButtonProps = {
model: ModelType; model: ModelType;
pk: number | undefined; id: number | undefined;
}; };
/* /*
@ -46,12 +46,12 @@ export default function AdminButton(props: Readonly<AdminButtonProps>) {
} }
// No primary key provided // No primary key provided
if (!props.pk) { if (!props.id) {
return false; return false;
} }
return true; return true;
}, [user, props.model, props.pk]); }, [user, props.model, props.id]);
const openAdmin = useCallback( const openAdmin = useCallback(
(event: any) => { (event: any) => {
@ -63,7 +63,7 @@ export default function AdminButton(props: Readonly<AdminButtonProps>) {
} }
// Generate the URL for the admin interface // Generate the URL for the admin interface
const url = `${host}/${server.server.django_admin}${modelDef.admin_url}${props.pk}/`; const url = `${host}/${server.server.django_admin}${modelDef.admin_url}${props.id}/`;
if (event?.ctrlKey || event?.shiftKey) { if (event?.ctrlKey || event?.shiftKey) {
// Open the link in a new tab // Open the link in a new tab
@ -72,7 +72,7 @@ export default function AdminButton(props: Readonly<AdminButtonProps>) {
window.open(url, '_self'); window.open(url, '_self');
} }
}, },
[props.model, props.pk] [props.model, props.id]
); );
return ( return (
@ -80,7 +80,6 @@ export default function AdminButton(props: Readonly<AdminButtonProps>) {
icon={<IconUserStar />} icon={<IconUserStar />}
color='blue' color='blue'
size='lg' size='lg'
radius='sm'
variant='filled' variant='filled'
tooltip={t`Open in admin interface`} tooltip={t`Open in admin interface`}
hidden={!enabled} hidden={!enabled}

View File

@ -72,7 +72,6 @@ export function ActionDropdown({
<Menu.Target> <Menu.Target>
<Tooltip label={tooltip} hidden={!tooltip} position='bottom'> <Tooltip label={tooltip} hidden={!tooltip} position='bottom'>
<Button <Button
radius='sm'
variant={noindicator ? 'transparent' : 'light'} variant={noindicator ? 'transparent' : 'light'}
disabled={disabled} disabled={disabled}
aria-label={menuName} aria-label={menuName}

View File

@ -0,0 +1,94 @@
import { t } from '@lingui/macro';
import { IconRadar } from '@tabler/icons-react';
import { useEffect, useMemo, useState } from 'react';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import { usePluginsWithMixin } from '../../hooks/UsePlugins';
import { apiUrl } from '../../states/ApiState';
import { ActionButton } from '../buttons/ActionButton';
import type { ApiFormFieldSet } from '../forms/fields/ApiFormField';
import type { PluginInterface } from './PluginInterface';
export default function LocateItemButton({
stockId,
locationId
}: {
stockId?: number;
locationId?: number;
}) {
const locatePlugins = usePluginsWithMixin('locate');
const [selectedPlugin, setSelectedPlugin] = useState<string | undefined>(
undefined
);
useEffect(() => {
// Ensure that the selected plugin is in the list of available plugins
if (selectedPlugin && locatePlugins) {
const plugin = locatePlugins.find(
(plugin: PluginInterface) => plugin.key === selectedPlugin
);
if (!plugin) {
setSelectedPlugin(undefined);
}
} else {
setSelectedPlugin(locatePlugins[0]?.key ?? undefined);
}
}, [selectedPlugin, locatePlugins]);
const locateFields: ApiFormFieldSet = useMemo(() => {
return {
plugin: {
field_type: 'choice',
value: selectedPlugin,
onValueChange: (value: string) => {
setSelectedPlugin(value);
},
choices: locatePlugins.map((plugin: PluginInterface) => {
return {
value: plugin.key,
display_name: plugin.meta?.human_name ?? plugin.name
};
})
},
item: {
hidden: true,
value: stockId
},
location: {
hidden: true,
value: locationId
}
};
}, [stockId, locationId, locatePlugins]);
const locateForm = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.plugin_locate_item),
method: 'POST',
title: t`Locate Item`,
fields: locateFields,
successMessage: t`Item location requested`
});
if (!locatePlugins || locatePlugins.length === 0) {
return null;
}
if (!stockId && !locationId) {
return null;
}
return (
<>
{locateForm.modal}
<ActionButton
icon={<IconRadar />}
variant='outline'
size='lg'
tooltip={t`Locate Item`}
onClick={locateForm.open}
tooltipAlignment='bottom'
/>
</>
);
}

View File

@ -203,6 +203,9 @@ export enum ApiEndpoints {
// User interface plugin endpoints // User interface plugin endpoints
plugin_ui_features_list = 'plugins/ui/features/:feature_type/', plugin_ui_features_list = 'plugins/ui/features/:feature_type/',
// Special plugin endpoints
plugin_locate_item = 'locate/',
// Machine API endpoints // Machine API endpoints
machine_types_list = 'machine/types/', machine_types_list = 'machine/types/',
machine_driver_list = 'machine/drivers/', machine_driver_list = 'machine/drivers/',

View File

@ -0,0 +1,45 @@
import { useCallback } from 'react';
import type { PluginInterface } from '../components/plugins/PluginInterface';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { useInstance } from './UseInstance';
export interface UsePluginResult {
plugins: PluginInterface[];
withMixin: (mixin: string) => PluginInterface[];
}
/**
* Hook for storing information on active plugins
*/
export const usePlugins = (): UsePluginResult => {
const pluginQuery = useInstance({
endpoint: ApiEndpoints.plugin_list,
defaultValue: [],
hasPrimaryKey: false,
refetchOnMount: true,
refetchOnWindowFocus: false,
params: {
active: true
}
});
const pluginsWithMixin = useCallback(
(mixin: string) => {
return pluginQuery.instance.filter((plugin: PluginInterface) => {
return !!plugin.mixins[mixin];
});
},
[pluginQuery.instance]
);
return {
plugins: pluginQuery.instance,
withMixin: pluginsWithMixin
};
};
export const usePluginsWithMixin = (mixin: string): PluginInterface[] => {
const plugins = usePlugins();
return plugins.withMixin(mixin);
};

View File

@ -466,7 +466,7 @@ export default function BuildDetail() {
color='green' color='green'
onClick={completeOrder.open} onClick={completeOrder.open}
/>, />,
<AdminButton model={ModelType.build} pk={build.pk} />, <AdminButton model={ModelType.build} id={build.pk} />,
<BarcodeActionDropdown <BarcodeActionDropdown
model={ModelType.build} model={ModelType.build}
pk={build.pk} pk={build.pk}

View File

@ -287,7 +287,7 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
const companyActions = useMemo(() => { const companyActions = useMemo(() => {
return [ return [
<AdminButton model={ModelType.company} pk={company.pk} />, <AdminButton model={ModelType.company} id={company.pk} />,
<OptionsActionDropdown <OptionsActionDropdown
tooltip={t`Company Actions`} tooltip={t`Company Actions`}
actions={[ actions={[

View File

@ -234,7 +234,7 @@ export default function ManufacturerPartDetail() {
<AdminButton <AdminButton
key='admin' key='admin'
model={ModelType.manufacturerpart} model={ModelType.manufacturerpart}
pk={manufacturerPart.pk} id={manufacturerPart.pk}
/>, />,
<OptionsActionDropdown <OptionsActionDropdown
key='options' key='options'

View File

@ -276,7 +276,7 @@ export default function SupplierPartDetail() {
const supplierPartActions = useMemo(() => { const supplierPartActions = useMemo(() => {
return [ return [
<AdminButton model={ModelType.supplierpart} pk={supplierPart.pk} />, <AdminButton model={ModelType.supplierpart} id={supplierPart.pk} />,
<BarcodeActionDropdown <BarcodeActionDropdown
model={ModelType.supplierpart} model={ModelType.supplierpart}
pk={supplierPart.pk} pk={supplierPart.pk}

View File

@ -223,7 +223,7 @@ export default function CategoryDetail() {
<AdminButton <AdminButton
key='admin' key='admin'
model={ModelType.partcategory} model={ModelType.partcategory}
pk={category.pk} id={category.pk}
/>, />,
<OptionsActionDropdown <OptionsActionDropdown
key='category-actions' key='category-actions'

View File

@ -996,7 +996,7 @@ export default function PartDetail() {
const partActions = useMemo(() => { const partActions = useMemo(() => {
return [ return [
<AdminButton model={ModelType.part} pk={part.pk} />, <AdminButton model={ModelType.part} id={part.pk} />,
<BarcodeActionDropdown <BarcodeActionDropdown
model={ModelType.part} model={ModelType.part}
pk={part.pk} pk={part.pk}

View File

@ -413,7 +413,7 @@ export default function PurchaseOrderDetail() {
color='green' color='green'
onClick={completeOrder.open} onClick={completeOrder.open}
/>, />,
<AdminButton model={ModelType.purchaseorder} pk={order.pk} />, <AdminButton model={ModelType.purchaseorder} id={order.pk} />,
<BarcodeActionDropdown <BarcodeActionDropdown
model={ModelType.purchaseorder} model={ModelType.purchaseorder}
pk={order.pk} pk={order.pk}

View File

@ -412,7 +412,7 @@ export default function ReturnOrderDetail() {
color='green' color='green'
onClick={() => completeOrder.open()} onClick={() => completeOrder.open()}
/>, />,
<AdminButton model={ModelType.returnorder} pk={order.pk} />, <AdminButton model={ModelType.returnorder} id={order.pk} />,
<BarcodeActionDropdown <BarcodeActionDropdown
model={ModelType.returnorder} model={ModelType.returnorder}
pk={order.pk} pk={order.pk}

View File

@ -453,7 +453,7 @@ export default function SalesOrderDetail() {
color='green' color='green'
onClick={completeOrder.open} onClick={completeOrder.open}
/>, />,
<AdminButton model={ModelType.salesorder} pk={order.pk} />, <AdminButton model={ModelType.salesorder} id={order.pk} />,
<BarcodeActionDropdown <BarcodeActionDropdown
model={ModelType.salesorder} model={ModelType.salesorder}
pk={order.pk} pk={order.pk}

View File

@ -25,6 +25,7 @@ import NavigationTree from '../../components/nav/NavigationTree';
import { PageDetail } from '../../components/nav/PageDetail'; import { PageDetail } from '../../components/nav/PageDetail';
import type { PanelType } from '../../components/panels/Panel'; import type { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup'; import { PanelGroup } from '../../components/panels/PanelGroup';
import LocateItemButton from '../../components/plugins/LocateItemButton';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType'; import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles'; import { UserRoles } from '../../enums/Roles';
@ -275,7 +276,8 @@ export default function Stock() {
const locationActions = useMemo( const locationActions = useMemo(
() => [ () => [
<AdminButton model={ModelType.stocklocation} pk={location.pk} />, <AdminButton model={ModelType.stocklocation} id={location.pk} />,
<LocateItemButton locationId={location.pk} />,
<ActionButton <ActionButton
icon={<InvenTreeIcon icon='stocktake' />} icon={<InvenTreeIcon icon='stocktake' />}
onClick={notYetImplemented} onClick={notYetImplemented}

View File

@ -39,6 +39,7 @@ import AttachmentPanel from '../../components/panels/AttachmentPanel';
import NotesPanel from '../../components/panels/NotesPanel'; import NotesPanel from '../../components/panels/NotesPanel';
import type { PanelType } from '../../components/panels/Panel'; import type { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup'; import { PanelGroup } from '../../components/panels/PanelGroup';
import LocateItemButton from '../../components/plugins/LocateItemButton';
import { StatusRenderer } from '../../components/render/StatusRenderer'; import { StatusRenderer } from '../../components/render/StatusRenderer';
import { formatCurrency } from '../../defaults/formatters'; import { formatCurrency } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
@ -653,7 +654,8 @@ export default function StockDetail() {
stockitem.quantity == 1; stockitem.quantity == 1;
return [ return [
<AdminButton model={ModelType.stockitem} pk={stockitem.pk} />, <AdminButton model={ModelType.stockitem} id={stockitem.pk} />,
<LocateItemButton stockId={stockitem.pk} />,
<BarcodeActionDropdown <BarcodeActionDropdown
model={ModelType.stockitem} model={ModelType.stockitem}
pk={stockitem.pk} pk={stockitem.pk}

View File

@ -115,7 +115,7 @@ export function GroupDrawer({
<Title order={5}> <Title order={5}>
<Trans>Permission set</Trans> <Trans>Permission set</Trans>
</Title> </Title>
<AdminButton model={ModelType.group} pk={instance.pk} /> <AdminButton model={ModelType.group} id={instance.pk} />
</Group> </Group>
<Group>{permissionsAccordion}</Group> <Group>{permissionsAccordion}</Group>
</Stack> </Stack>

View File

@ -94,3 +94,35 @@ test('Plugins - Custom Admin', async ({ page, request }) => {
await page.getByText('foo: bar').waitFor(); await page.getByText('foo: bar').waitFor();
await page.getByText('hello: world').waitFor(); await page.getByText('hello: world').waitFor();
}); });
test('Plugins - Locate Item', async ({ page, request }) => {
await doQuickLogin(page, 'admin', 'inventree');
// Ensure that the sample location plugin is enabled
await setPluginState({
request,
plugin: 'samplelocate',
state: true
});
await page.waitForTimeout(500);
// Navigate to the "stock item" page
await page.goto(`${baseUrl}/stock/item/287/`);
// "Locate" this item
await page.getByLabel('action-button-locate-item').click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Item location requested').waitFor();
// Show the location
await page.getByLabel('breadcrumb-1-factory').click();
await page.waitForTimeout(500);
await page.getByLabel('action-button-locate-item').click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Item location requested').waitFor();
await page.waitForTimeout(2500);
return;
});