2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +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 InvenTree.tasks import offload_task
from plugin.registry import registry
from plugin.registry import call_plugin_function, registry
from stock.models import StockItem, StockLocation
@ -59,7 +59,7 @@ class LocatePluginView(GenericAPIView):
StockItem.objects.get(pk=item_pk)
offload_task(
registry.call_plugin_function,
call_plugin_function,
plugin,
'locate_stock_item',
item_pk,
@ -78,7 +78,7 @@ class LocatePluginView(GenericAPIView):
StockLocation.objects.get(pk=location_pk)
offload_task(
registry.call_plugin_function,
call_plugin_function,
plugin,
'locate_stock_location',
location_pk,

View File

@ -159,7 +159,7 @@ class PluginsRegistry:
# Update the registry hash value
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'.
As this is intended to be run by the background worker,
@ -807,7 +807,7 @@ class 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."""
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.helpers import MixinImplementationError
from plugin.mixins import ScheduleMixin
from plugin.registry import call_function
from plugin.registry import call_plugin_function
class ExampleScheduledTaskPluginTests(TestCase):
@ -67,10 +67,10 @@ class ExampleScheduledTaskPluginTests(TestCase):
def test_calling(self):
"""Check if a function can be called without errors."""
# 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
self.assertEqual(call_function('does_not_exist', 'member_func'), None)
self.assertEqual(call_plugin_function('does_not_exist', 'member_func'), None)
class ScheduledTaskPluginTests(TestCase):

View File

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

View File

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

View File

@ -72,7 +72,6 @@ export function ActionDropdown({
<Menu.Target>
<Tooltip label={tooltip} hidden={!tooltip} position='bottom'>
<Button
radius='sm'
variant={noindicator ? 'transparent' : 'light'}
disabled={disabled}
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
plugin_ui_features_list = 'plugins/ui/features/:feature_type/',
// Special plugin endpoints
plugin_locate_item = 'locate/',
// Machine API endpoints
machine_types_list = 'machine/types/',
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'
onClick={completeOrder.open}
/>,
<AdminButton model={ModelType.build} pk={build.pk} />,
<AdminButton model={ModelType.build} id={build.pk} />,
<BarcodeActionDropdown
model={ModelType.build}
pk={build.pk}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -94,3 +94,35 @@ test('Plugins - Custom Admin', async ({ page, request }) => {
await page.getByText('foo: bar').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;
});