2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-09-13 22:21:37 +00:00

[Refactor] Notification plugins (#9735)

* Refactor notification concept

- Notifications handled by plugins

* Cleanup

* Only send email if template provided in context

* Logic cleanup

* Fix log_error call

* Refactor error logging

- Ensure plugin slug is correctly attached
- Consistent format
- Logic fixes

* More robust plugin lookup

* Refactor calls to tringger_notification

* Tweak for build stock notification

* Low stock notification refactor

- Actually *use* the notification system
- Fix for email template

* Check stock only when build is issued

* Updated documentation

* Add PluginUserSetting class

- Allows plugins to define per-user settings

* Add API endpoints for PluginUserSetting model

* Placeholder for user-plugin-settings page

* Refactoring frontend code

* Placeholder panel

* Adds user interface for changing user-specific plugin settings

* Tweaks

* Remove old model

* Update documentation

* Playwright tests

* Update API version

* Fix unit test

* Fix removed arg

* Fixes for email notifications

- Track status of sending notifications
- Add helper "activate" method for plugin class
- Update unit tests

* Fix barcode tests

* More unit test fixes

* Test fixes

* Fix for settings models with extra fields

* Enhance unit test

* Remove old test file

* Check for null target_fnc

* Improve DB query efficiency

- Provide a flat list of active keys to plugin.is_active
- Prevents DB fetching (in certain circumstances)
- Add registry.active_plugins() method

* Bump query limit up for test

- In practice, this API endpoint is ~10 queries

* Handle potential errors

* Increase query limit for API test

* Increase query limit for some tests

* Bump API version

* Tweak unit test

* Tweak unit test

* Increased allowed queries

* fix user plugin settings

* Fix for unit test

* Update debug msg

* Tweak API

* Fix endpoint

* Remove "active plugin keys" code

* Restore previous behaviour

* Fix unit tests

* Tweak unit test

* Update src/backend/InvenTree/build/tasks.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/backend/InvenTree/plugin/base/integration/NotificationMixin.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Func updates

* Format

* Add notification settings

* Refactor plugin settings groups

* Fix func type

* Adjust message

* Additional unit tests

* Additional playwright tests

* Additional playwright test

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Oliver
2025-07-26 13:05:59 +10:00
committed by GitHub
parent f862867e2b
commit 1085625af4
60 changed files with 1329 additions and 972 deletions

View File

@@ -208,6 +208,7 @@ export enum ApiEndpoints {
// Plugin API endpoints
plugin_list = 'plugins/',
plugin_setting_list = 'plugins/:plugin/settings/',
plugin_user_setting_list = 'plugins/:plugin/user-settings/',
plugin_registry_status = 'plugins/status/',
plugin_install = 'plugins/install/',
plugin_reload = 'plugins/reload/',

View File

@@ -2,9 +2,16 @@ import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { Alert, Skeleton, Stack, Text } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState
} from 'react';
import { useStore } from 'zustand';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import type { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import type { Setting, SettingsStateProps } from '@lib/types/Settings';
@@ -25,12 +32,21 @@ import { SettingItem } from './SettingItem';
export function SettingList({
settingsState,
keys,
onChange
onChange,
onLoaded
}: Readonly<{
settingsState: SettingsStateProps;
keys?: string[];
onChange?: () => void;
onLoaded?: (settings: SettingsStateProps) => void;
}>) {
useEffect(() => {
if (settingsState.loaded) {
// Call the onLoaded callback if provided
onLoaded?.(settingsState);
}
}, [settingsState.loaded, settingsState.settings]);
const api = useApi();
const allKeys = useMemo(
@@ -189,14 +205,39 @@ export function GlobalSettingList({ keys }: Readonly<{ keys: string[] }>) {
}
export function PluginSettingList({
pluginKey
}: Readonly<{ pluginKey: string }>) {
pluginKey,
onLoaded
}: Readonly<{
pluginKey: string;
onLoaded?: (settings: SettingsStateProps) => void;
}>) {
const pluginSettingsStore = useRef(
createPluginSettingsState({ plugin: pluginKey })
createPluginSettingsState({
plugin: pluginKey,
endpoint: ApiEndpoints.plugin_setting_list
})
).current;
const pluginSettings = useStore(pluginSettingsStore);
return <SettingList settingsState={pluginSettings} />;
return <SettingList settingsState={pluginSettings} onLoaded={onLoaded} />;
}
export function PluginUserSettingList({
pluginKey,
onLoaded
}: Readonly<{
pluginKey: string;
onLoaded?: (settings: SettingsStateProps) => void;
}>) {
const pluginUserSettingsState = useRef(
createPluginSettingsState({
plugin: pluginKey,
endpoint: ApiEndpoints.plugin_user_setting_list
})
).current;
const pluginUserSettings = useStore(pluginUserSettingsState);
return <SettingList settingsState={pluginUserSettings} onLoaded={onLoaded} />;
}
export function MachineSettingList({

View File

@@ -0,0 +1,117 @@
import { ApiEndpoints } from '@lib/index';
import type { SettingsStateProps } from '@lib/types/Settings';
import { t } from '@lingui/core/macro';
import { Accordion, Alert, Group, Stack, Text } from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
import { useCallback, useMemo, useState } from 'react';
import {
PluginSettingList,
PluginUserSettingList
} from '../../../components/settings/SettingList';
import { useInstance } from '../../../hooks/UseInstance';
function PluginSettingGroupItem({
global,
pluginKey,
pluginName,
pluginDescription
}: {
global: boolean;
pluginKey: string;
pluginName: string;
pluginDescription?: string;
}) {
// Hide the accordion item if there are no settings for this plugin
const [count, setCount] = useState<number>(0);
// Callback once the plugin settings have been loaded
const onLoaded = useCallback(
(settings: SettingsStateProps) => {
setCount(settings.settings?.length || 0);
},
[pluginKey]
);
return (
<Accordion.Item
key={`plugin-${pluginKey}`}
value={pluginKey}
hidden={count === 0}
>
<Accordion.Control>
<Group>
<Text size='lg'>{pluginName}</Text>
{pluginDescription && <Text size='sm'>{pluginDescription}</Text>}
</Group>
</Accordion.Control>
<Accordion.Panel>
{global ? (
<PluginSettingList pluginKey={pluginKey} onLoaded={onLoaded} />
) : (
<PluginUserSettingList pluginKey={pluginKey} onLoaded={onLoaded} />
)}
</Accordion.Panel>
</Accordion.Item>
);
}
/**
* Displays an accordion of user-specific plugin settings
* - Each element in the accordion corresponds to a plugin
* - Each plugin can have multiple settings
* - If a plugin has no settings, it will not be displayed
*/
export default function PluginSettingsGroup({
mixin,
message,
global
}: {
global: boolean;
message?: string;
mixin?: string;
}) {
const mixins: string = useMemo(() => {
const mixinList: string[] = ['settings'];
if (mixin) {
mixinList.push(mixin);
}
return mixinList.join(',');
}, [mixin]);
// All *active* plugins which require settings
const activePlugins = useInstance({
endpoint: ApiEndpoints.plugin_list,
params: {
active: true,
mixin: mixins
},
hasPrimaryKey: false,
defaultValue: []
});
return (
<Stack gap='xs'>
<Alert color='blue' icon={<IconInfoCircle />}>
<Text>
{message ??
t`The settings below are specific to each available plugin`}
</Text>
</Alert>
<Accordion multiple>
{activePlugins.instance?.map((plugin: any) => {
return (
<PluginSettingGroupItem
global={global}
key={plugin.key}
pluginKey={plugin.key}
pluginName={plugin.meta?.human_name ?? plugin.name}
pluginDescription={plugin?.meta?.description}
/>
);
})}
</Accordion>
</Stack>
);
}

View File

@@ -1,13 +1,13 @@
import { t } from '@lingui/core/macro';
import { Alert, Skeleton, Stack, Text } from '@mantine/core';
import { Skeleton, Stack } from '@mantine/core';
import {
IconBellCog,
IconCategory,
IconCurrencyDollar,
IconFileAnalytics,
IconFingerprint,
IconInfoCircle,
IconPackages,
IconPlugConnected,
IconQrcode,
IconServerCog,
IconShoppingCart,
@@ -16,7 +16,7 @@ import {
IconTruckDelivery,
IconTruckReturn
} from '@tabler/icons-react';
import { useMemo } from 'react';
import { lazy, useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
import PermissionDenied from '../../../components/errors/PermissionDenied';
@@ -25,9 +25,14 @@ import { SettingsHeader } from '../../../components/nav/SettingsHeader';
import type { PanelType } from '../../../components/panels/Panel';
import { PanelGroup } from '../../../components/panels/PanelGroup';
import { GlobalSettingList } from '../../../components/settings/SettingList';
import { Loadable } from '../../../functions/loading';
import { useServerApiState } from '../../../states/ServerApiState';
import { useUserState } from '../../../states/UserState';
const PluginSettingsGroup = Loadable(
lazy(() => import('./PluginSettingsGroup'))
);
/**
* System settings page
*/
@@ -113,15 +118,11 @@ export default function SystemSettings() {
label: t`Notifications`,
icon: <IconBellCog />,
content: (
<Stack>
<Alert
color='teal'
title={t`This panel is a placeholder.`}
icon={<IconInfoCircle />}
>
<Text c='gray'>This panel has not yet been implemented</Text>
</Alert>
</Stack>
<PluginSettingsGroup
mixin='notification'
global={true}
message={t`The settings below are specific to each available notification method`}
/>
)
},
{
@@ -305,6 +306,12 @@ export default function SystemSettings() {
]}
/>
)
},
{
name: 'plugins',
label: t`Plugin Settings`,
icon: <IconPlugConnected />,
content: <PluginSettingsGroup global={true} />
}
];
}, []);

View File

@@ -5,10 +5,11 @@ import {
IconDeviceDesktop,
IconFileAnalytics,
IconLock,
IconPlugConnected,
IconSearch,
IconUserCircle
} from '@tabler/icons-react';
import { useMemo } from 'react';
import { lazy, useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
import PageTitle from '../../../components/nav/PageTitle';
@@ -16,10 +17,15 @@ import { SettingsHeader } from '../../../components/nav/SettingsHeader';
import type { PanelType } from '../../../components/panels/Panel';
import { PanelGroup } from '../../../components/panels/PanelGroup';
import { UserSettingList } from '../../../components/settings/SettingList';
import { Loadable } from '../../../functions/loading';
import { useUserState } from '../../../states/UserState';
import { SecurityContent } from './AccountSettings/SecurityContent';
import { AccountContent } from './AccountSettings/UserPanel';
const PluginSettingsGroup = Loadable(
lazy(() => import('./PluginSettingsGroup'))
);
/**
* User settings page
*/
@@ -97,7 +103,13 @@ export default function UserSettings() {
name: 'notifications',
label: t`Notifications`,
icon: <IconBellCog />,
content: <UserSettingList keys={['NOTIFICATION_ERROR_REPORT']} />
content: (
<PluginSettingsGroup
mixin='notification'
global={false}
message={t`The settings below are specific to each available notification method`}
/>
)
},
{
name: 'reporting',
@@ -108,6 +120,12 @@ export default function UserSettings() {
keys={['REPORT_INLINE', 'LABEL_INLINE', 'LABEL_DEFAULT_PRINTER']}
/>
)
},
{
name: 'plugins',
label: t`Plugin Settings`,
icon: <IconPlugConnected />,
content: <PluginSettingsGroup global={false} />
}
];
}, []);

View File

@@ -123,10 +123,12 @@ export const useUserSettingsState = create<SettingsStateProps>((set, get) => ({
*/
interface CreatePluginSettingStateProps {
plugin: string;
endpoint: ApiEndpoints;
}
export const createPluginSettingsState = ({
plugin
plugin,
endpoint
}: CreatePluginSettingStateProps) => {
const pathParams: PathParams = { plugin };
@@ -135,7 +137,7 @@ export const createPluginSettingsState = ({
lookup: {},
loaded: false,
isError: false,
endpoint: ApiEndpoints.plugin_setting_list,
endpoint: endpoint,
pathParams,
fetchSettings: async () => {
let success = true;
@@ -155,7 +157,7 @@ export const createPluginSettingsState = ({
});
await api
.get(apiUrl(ApiEndpoints.plugin_setting_list, undefined, { plugin }))
.get(apiUrl(endpoint, undefined, { plugin }))
.then((response) => {
const settings = response.data;
set({
@@ -166,7 +168,9 @@ export const createPluginSettingsState = ({
});
})
.catch((_error) => {
console.error(`Error fetching plugin settings for plugin ${plugin}`);
console.error(
`ERR: Could not fetch plugin settings for plugin ${plugin}`
);
success = false;
set({
loaded: false,
@@ -186,7 +190,6 @@ export const createPluginSettingsState = ({
}));
useEffect(() => {
console.log('fetching plugin settings for', plugin);
store.getState().fetchSettings();
}, [plugin]);

View File

@@ -65,6 +65,47 @@ test('Plugins - Settings', async ({ browser, request }) => {
await page.getByText('Mouser Electronics').click();
});
test('Plugins - User Settings', async ({ browser, request }) => {
const page = await doCachedLogin(browser);
// Ensure that the SampleIntegration plugin is enabled
await setPluginState({
request,
plugin: 'sample',
state: true
});
// Navigate to user settings
await navigate(page, 'settings/user/');
await loadTab(page, 'Plugin Settings');
// User settings for the "Sample Plugin" should be visible
await page.getByRole('button', { name: 'Sample Plugin' }).click();
await page.getByText('User Setting 1').waitFor();
await page.getByText('User Setting 2').waitFor();
await page.getByText('User Setting 3').waitFor();
// Check for expected setting options
await page.getByLabel('edit-setting-USER_SETTING_3').click();
const val = await page.getByLabel('choice-field-value').inputValue();
await page.getByLabel('choice-field-value').click();
await page.getByRole('option', { name: 'Choice X' }).waitFor();
await page.getByRole('option', { name: 'Choice Y' }).waitFor();
await page.getByRole('option', { name: 'Choice Z' }).waitFor();
// Change the value of USER_SETTING_3
await page
.getByRole('option', { name: val == 'Choice X' ? 'Choice Z' : 'Choice X' })
.click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Setting USER_SETTING_3 updated successfully').waitFor();
});
// Test base plugin functionality
test('Plugins - Functionality', async ({ browser }) => {
// Navigate and select the plugin

View File

@@ -2,7 +2,7 @@ import { expect, test } from './baseFixtures.js';
import { apiUrl } from './defaults.js';
import { getRowFromCell, loadTab, navigate } from './helpers.js';
import { doCachedLogin } from './login.js';
import { setSettingState } from './settings.js';
import { setPluginState, setSettingState } from './settings.js';
/**
* Adjust language and color settings
@@ -80,6 +80,97 @@ test('Settings - User theme', async ({ browser }) => {
await page.getByLabel('#228be6').click();
});
test('Settings - User', async ({ browser }) => {
const page = await doCachedLogin(browser, {
username: 'allaccess',
password: 'nolimits',
url: 'settings/user/'
});
await loadTab(page, 'Account');
await page.getByText('Account Details').waitFor();
await page.getByText('Profile Details').waitFor();
await loadTab(page, 'Security');
await page.getByRole('button', { name: 'Single Sign On' }).waitFor();
await page.getByRole('button', { name: 'Access Tokens' }).waitFor();
await loadTab(page, 'Display Options');
await page
.getByText('The navbar position is fixed to the top of the screen')
.waitFor();
await page.getByText('Escape Key Closes Forms').waitFor();
await loadTab(page, 'Search');
await page.getByText('Whole Word Search').waitFor();
await page.getByText('Hide Unavailable Stock Items').waitFor();
await loadTab(page, 'Notifications');
await page
.getByRole('button', { name: 'InvenTree Email Notifications' })
.waitFor();
await loadTab(page, 'Reporting');
await page.getByText('Inline report display').waitFor();
await loadTab(page, 'Plugin Settings');
await page
.getByRole('button', { name: 'InvenTree Email Notifications' })
.waitFor();
});
test('Settings - Global', async ({ browser, request }) => {
const page = await doCachedLogin(browser, {
username: 'steven',
password: 'wizardstaff',
url: 'settings/system/'
});
// Ensure the "slack" notification plugin is enabled
// This is to ensure it is visible in the "notification" settings tab
await setPluginState({
request,
plugin: 'inventree-slack-notification',
state: true
});
await loadTab(page, 'Server');
await loadTab(page, 'Authentication');
await loadTab(page, 'Barcodes');
await loadTab(page, 'Pricing');
await loadTab(page, 'Parts');
await loadTab(page, 'Stock');
await loadTab(page, 'Notifications');
await page
.getByText(
'The settings below are specific to each available notification method'
)
.waitFor();
await page
.getByRole('button', { name: 'InvenTree Slack Notifications' })
.click();
await page.getByText('Slack incoming webhook url').waitFor();
await page
.getByText('URL that is used to send messages to a slack channel')
.waitFor();
await loadTab(page, 'Plugin Settings');
await page
.getByText('The settings below are specific to each available plugin')
.waitFor();
await page
.getByRole('button', { name: 'InvenTree Barcodes Provides' })
.waitFor();
await page
.getByRole('button', { name: 'InvenTree PDF label printer' })
.waitFor();
await page
.getByRole('button', { name: 'InvenTree Slack Notifications' })
.waitFor();
});
test('Settings - Admin', async ({ browser }) => {
// Note here we login with admin access
const page = await doCachedLogin(browser, {