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:
parent
7b50f3b1d3
commit
246f17113f
@ -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,
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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):
|
||||
|
@ -880,6 +880,7 @@ class StockApiMixin:
|
||||
|
||||
for key in [
|
||||
'part_detail',
|
||||
'path_detail',
|
||||
'location_detail',
|
||||
'supplier_part_detail',
|
||||
'tests',
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
94
src/frontend/src/components/plugins/LocateItemButton.tsx
Normal file
94
src/frontend/src/components/plugins/LocateItemButton.tsx
Normal 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'
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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/',
|
||||
|
45
src/frontend/src/hooks/UsePlugins.tsx
Normal file
45
src/frontend/src/hooks/UsePlugins.tsx
Normal 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);
|
||||
};
|
@ -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}
|
||||
|
@ -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={[
|
||||
|
@ -234,7 +234,7 @@ export default function ManufacturerPartDetail() {
|
||||
<AdminButton
|
||||
key='admin'
|
||||
model={ModelType.manufacturerpart}
|
||||
pk={manufacturerPart.pk}
|
||||
id={manufacturerPart.pk}
|
||||
/>,
|
||||
<OptionsActionDropdown
|
||||
key='options'
|
||||
|
@ -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}
|
||||
|
@ -223,7 +223,7 @@ export default function CategoryDetail() {
|
||||
<AdminButton
|
||||
key='admin'
|
||||
model={ModelType.partcategory}
|
||||
pk={category.pk}
|
||||
id={category.pk}
|
||||
/>,
|
||||
<OptionsActionDropdown
|
||||
key='category-actions'
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user