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

[WIP] Plugin Updates (#6400)

* Add method to extract "install name" from a plugin

* Include more information in plugin meta serializer

* Implement better API filtering for PluginConfig list

* Add an "update" button to the plugin table row actions

- Only for "package" plugins

* Adds method to update a plugin:

- Add plugin.installer.update_plugin method
- Add error logging to existing methods
- Add API endpoint and serializer
- Integrate into PUI table

* Implement lazy loading for plugin tables

* Extract package information on registry load

- Info is already available via entrypoint data
- Significantly faster as introspection operation is expensive
- Less code is good code

* Frontend updates

* Add accordion to plugin page

* Add setting to control periodic version check updates

* Update API version info

* Add "package_name" field to PluginConfig

- When the plugin is loaded, save this name to the PluginConfig model
- Update the admin view

* Update API serializer

* Refactor plugin installer code

- Add common functions

* Adds API endpoint for uninstalling an installed plugin

* Allow uninstall of plugin via API

- Add API endpoints
- Add UI elements

* Tweak for admin list display

* Update plugin

* Refactor "update" method

- Just use the "install" function
- Add optional "version" specifier
- UI updates

* Allow deletion of PluginConfig when uninstalling plugin

* Add placeholder for deleting database tables

* Revert code change

- get_object() is required

* Use registry.get_plugin()

- Instead of registry.plugins.get()
- get_plugin checks registry hash
- performs registry reload if necessary

* Add PluginValidationMixin class

- Allows the entire model to be validated via plugins
- Called on model.full_clean()
- Called on model.save()

* Update Validation sample plugin

* Fix for InvenTreeTree models

* Refactor build.models

- Expose models to plugin validation

* Update stock.models

* Update more models

- common.models
- company.models

* Update more models

- label.models
- order.models
- part.models

* More model updates

* Update docs

* Fix for potential plugin edge case

- plugin slug is globally unique
- do not use get_or_create with two lookup fields
- will throw an IntegrityError if you change the name of a plugin

* Inherit DiffMixin into PluginValidationMixin

- Allows us to pass model diffs through to validation
- Plugins can validate based on what has *changed*

* Update documentation

* Add get_plugin_config helper function

* Bug fix

* Bug fix

* Update plugin hash when calling set_plugin_state

* Working on unit testing

* More unit testing

* Fix typo (installing -> uninstalling)

* Reduce default timeout

* set default timeout as part of ApiDefaults

* revert changes to launch.json

* Remove delete_tables field

- Will come back in a future PR

* Fix display of nonFIeldErrors in ApiForm.tsx

* Allow deletion of deleted plugins

- PluginConfig which no longer matches a valid (installed) plugin

* Cleanup

* Move get_plugin_config into registry.py

* Move extract_int into InvenTree.helpers

* Fix log formatting

* Update model definitions

- Ensure there are no changes to the migrations

* Update PluginErrorTable.tsx

Remove unused var

* Update PluginManagementPanel.tsx

remove unused var

* Comment out format line

* Comment out format line

* Fix access to get_plugin_config

* Fix tests for SimpleActionPlugin

* More unit test fixes

* Update plugin/installer.py

- Account for version string
- Remove on uninstall

* Fix
This commit is contained in:
Oliver
2024-02-07 02:08:30 +11:00
committed by GitHub
parent cd803640a9
commit c0c4e9c226
22 changed files with 607 additions and 189 deletions

View File

@ -21,6 +21,7 @@ export function setApiDefaults() {
const token = useSessionState.getState().token;
api.defaults.baseURL = host;
api.defaults.timeout = 1000;
if (!!token) {
api.defaults.headers.common['Authorization'] = `Token ${token}`;

View File

@ -79,6 +79,7 @@ export interface ApiFormProps {
onFormSuccess?: (data: any) => void;
onFormError?: () => void;
actions?: ApiFormAction[];
timeout?: number;
}
export function OptionsApiForm({
@ -296,6 +297,7 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
method: method,
url: url,
data: data,
timeout: props.timeout,
headers: {
'Content-Type': hasFiles ? 'multipart/form-data' : 'application/json'
}
@ -339,13 +341,15 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
switch (error.response.status) {
case 400:
// Data validation errors
const nonFieldErrors: string[] = [];
const _nonFieldErrors: string[] = [];
const processErrors = (errors: any, _path?: string) => {
for (const [k, v] of Object.entries(errors)) {
const path = _path ? `${_path}.${k}` : k;
if (k === 'non_field_errors') {
nonFieldErrors.push((v as string[]).join(', '));
if (k === 'non_field_errors' || k === '__all__') {
if (Array.isArray(v)) {
_nonFieldErrors.push(...v);
}
continue;
}
@ -358,7 +362,7 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
};
processErrors(error.response.data);
setNonFieldErrors(nonFieldErrors);
setNonFieldErrors(_nonFieldErrors);
break;
default:
// Unexpected state on form error

View File

@ -97,6 +97,8 @@ export enum ApiEndpoints {
plugin_registry_status = 'plugins/status/',
plugin_install = 'plugins/install/',
plugin_reload = 'plugins/reload/',
plugin_activate = 'plugins/:id/activate/',
plugin_uninstall = 'plugins/:id/uninstall/',
// Miscellaneous API endpoints
error_report_list = 'error-report/',

View File

@ -1,11 +1,20 @@
import { Trans } from '@lingui/macro';
import { Alert, Stack, Title } from '@mantine/core';
import { Trans, t } from '@lingui/macro';
import { Accordion, Alert, Stack } from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
import { lazy } from 'react';
import { StylishText } from '../../../../components/items/StylishText';
import { GlobalSettingList } from '../../../../components/settings/SettingList';
import { Loadable } from '../../../../functions/loading';
import { useServerApiState } from '../../../../states/ApiState';
import { PluginErrorTable } from '../../../../tables/plugin/PluginErrorTable';
import { PluginListTable } from '../../../../tables/plugin/PluginListTable';
const PluginListTable = Loadable(
lazy(() => import('../../../../tables/plugin/PluginListTable'))
);
const PluginErrorTable = Loadable(
lazy(() => import('../../../../tables/plugin/PluginErrorTable'))
);
export default function PluginManagementPanel() {
const pluginsEnabled = useServerApiState(
@ -26,30 +35,44 @@ export default function PluginManagementPanel() {
</Alert>
)}
<PluginListTable props={{}} />
<Accordion defaultValue="pluginlist">
<Accordion.Item value="pluginlist">
<Accordion.Control>
<StylishText size="lg">{t`Plugins`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<PluginListTable />
</Accordion.Panel>
</Accordion.Item>
<Stack spacing={'xs'}>
<Title order={5}>
<Trans>Plugin Error Stack</Trans>
</Title>
<PluginErrorTable props={{}} />
</Stack>
<Accordion.Item value="pluginerror">
<Accordion.Control>
<StylishText size="lg">{t`Plugin Errors`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<PluginErrorTable />
</Accordion.Panel>
</Accordion.Item>
<Stack spacing={'xs'}>
<Title order={5}>
<Trans>Plugin Settings</Trans>
</Title>
<GlobalSettingList
keys={[
'ENABLE_PLUGINS_SCHEDULE',
'ENABLE_PLUGINS_EVENTS',
'ENABLE_PLUGINS_URL',
'ENABLE_PLUGINS_NAVIGATION',
'ENABLE_PLUGINS_APP',
'PLUGIN_ON_STARTUP'
]}
/>
</Stack>
<Accordion.Item value="pluginsettings">
<Accordion.Control>
<StylishText size="lg">{t`Plugin Settings`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<GlobalSettingList
keys={[
'ENABLE_PLUGINS_SCHEDULE',
'ENABLE_PLUGINS_EVENTS',
'ENABLE_PLUGINS_URL',
'ENABLE_PLUGINS_NAVIGATION',
'ENABLE_PLUGINS_APP',
'PLUGIN_ON_STARTUP',
'PLUGIN_UPDATE_CHECK'
]}
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
);
}

View File

@ -127,9 +127,7 @@ export function AddressTable({
onFormSuccess: table.refreshTable
});
const [selectedAddress, setSelectedAddress] = useState<number | undefined>(
undefined
);
const [selectedAddress, setSelectedAddress] = useState<number>(-1);
const editAddress = useEditApiFormModal({
url: ApiEndpoints.address_list,

View File

@ -88,9 +88,7 @@ export function PartCategoryTable({ parentId }: { parentId?: any }) {
}
});
const [selectedCategory, setSelectedCategory] = useState<number | undefined>(
undefined
);
const [selectedCategory, setSelectedCategory] = useState<number>(-1);
const editCategory = useEditApiFormModal({
url: ApiEndpoints.category_list,

View File

@ -85,9 +85,7 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) {
onFormSuccess: table.refreshTable
});
const [selectedTest, setSelectedTest] = useState<number | undefined>(
undefined
);
const [selectedTest, setSelectedTest] = useState<number>(-1);
const editTestTemplate = useEditApiFormModal({
url: ApiEndpoints.part_test_template_list,

View File

@ -6,7 +6,7 @@ import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { TableColumn } from '../Column';
import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable';
import { InvenTreeTable } from '../InvenTreeTable';
export interface PluginRegistryErrorI {
id: number;
@ -18,7 +18,7 @@ export interface PluginRegistryErrorI {
/**
* Table displaying list of plugin registry errors
*/
export function PluginErrorTable({ props }: { props: InvenTreeTableProps }) {
export default function PluginErrorTable() {
const table = useTable('registryErrors');
const registryErrorTableColumns: TableColumn<PluginRegistryErrorI>[] =
@ -47,16 +47,12 @@ export function PluginErrorTable({ props }: { props: InvenTreeTableProps }) {
tableState={table}
columns={registryErrorTableColumns}
props={{
...props,
dataFormatter: (data: any) =>
data.registry_errors.map((e: any, i: number) => ({ id: i, ...e })),
idAccessor: 'id',
enableDownload: false,
enableFilters: false,
enableSearch: false,
params: {
...props.params
}
enableSearch: false
}}
/>
);

View File

@ -16,11 +16,12 @@ import {
IconCircleCheck,
IconCircleX,
IconHelpCircle,
IconInfoCircle,
IconPlaylistAdd,
IconRefresh
} from '@tabler/icons-react';
import { IconDots } from '@tabler/icons-react';
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../../App';
@ -35,13 +36,17 @@ import { DetailDrawer } from '../../components/nav/DetailDrawer';
import { PluginSettingList } from '../../components/settings/SettingList';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { openEditApiForm } from '../../functions/forms';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { useTable } from '../../hooks/UseTable';
import { apiUrl, useServerApiState } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { TableColumn } from '../Column';
import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowAction } from '../RowActions';
export interface PluginI {
@ -52,6 +57,8 @@ export interface PluginI {
is_builtin: boolean;
is_sample: boolean;
is_installed: boolean;
is_package: boolean;
package_name: string | null;
meta: {
author: string | null;
description: string | null;
@ -189,9 +196,16 @@ export function PluginDrawer({
<Trans>Package information</Trans>
</Title>
<Stack pos="relative" spacing="xs">
{plugin?.is_package && (
<InfoItem
type="text"
name={t`Package Name`}
value={plugin?.package_name}
/>
)}
<InfoItem
type="text"
name={t`Installation path`}
name={t`Installation Path`}
value={plugin?.meta.package_path}
/>
<InfoItem
@ -199,6 +213,11 @@ export function PluginDrawer({
name={t`Builtin`}
value={plugin?.is_builtin}
/>
<InfoItem
type="boolean"
name={t`Package`}
value={plugin?.is_package}
/>
</Stack>
</Stack>
</Card>
@ -247,9 +266,10 @@ function PluginIcon(plugin: PluginI) {
/**
* Table displaying list of available plugins
*/
export function PluginListTable({ props }: { props: InvenTreeTableProps }) {
export default function PluginListTable() {
const table = useTable('plugin');
const navigate = useNavigate();
const user = useUserState();
const pluginsEnabled = useServerApiState(
(state) => state.server.plugins_enabled
@ -337,7 +357,7 @@ export function PluginListTable({ props }: { props: InvenTreeTableProps }) {
confirm: t`Confirm`
},
onConfirm: () => {
let url = apiUrl(ApiEndpoints.plugin_list, plugin_id) + 'activate/';
let url = apiUrl(ApiEndpoints.plugin_activate, plugin_id);
const id = 'plugin-activate';
@ -349,7 +369,13 @@ export function PluginListTable({ props }: { props: InvenTreeTableProps }) {
});
api
.patch(url, { active: active })
.patch(
url,
{ active: active },
{
timeout: 30 * 1000
}
)
.then(() => {
table.refreshTable();
notifications.hide(id);
@ -376,42 +402,98 @@ export function PluginListTable({ props }: { props: InvenTreeTableProps }) {
);
// Determine available actions for a given plugin
function rowActions(record: any): RowAction[] {
let actions: RowAction[] = [];
const rowActions = useCallback(
(record: any) => {
// TODO: Plugin actions should be updated based on on the users's permissions
if (!record.is_builtin && record.is_installed) {
if (record.active) {
let actions: RowAction[] = [];
if (!record.is_builtin && record.is_installed) {
if (record.active) {
actions.push({
title: t`Deactivate`,
color: 'red',
icon: <IconCircleX />,
onClick: () => {
activatePlugin(record.pk, record.name, false);
}
});
} else {
actions.push({
title: t`Activate`,
color: 'green',
icon: <IconCircleCheck />,
onClick: () => {
activatePlugin(record.pk, record.name, true);
}
});
}
}
// Active 'package' plugins can be updated
if (record.active && record.is_package && record.package_name) {
actions.push({
title: t`Deactivate`,
color: 'red',
icon: <IconCircleX />,
title: t`Update`,
color: 'blue',
icon: <IconRefresh />,
onClick: () => {
activatePlugin(record.pk, record.name, false);
}
});
} else {
actions.push({
title: t`Activate`,
color: 'green',
icon: <IconCircleCheck />,
onClick: () => {
activatePlugin(record.pk, record.name, true);
setPluginPackage(record.package_name);
installPluginModal.open();
}
});
}
}
return actions;
}
// Inactive 'package' plugins can be uninstalled
if (
!record.active &&
record.is_installed &&
record.is_package &&
record.package_name
) {
actions.push({
title: t`Uninstall`,
color: 'red',
icon: <IconCircleX />,
onClick: () => {
setSelectedPlugin(record.pk);
uninstallPluginModal.open();
}
});
}
// Uninstalled 'package' plugins can be deleted
if (!record.is_installed) {
actions.push({
title: t`Delete`,
color: 'red',
icon: <IconCircleX />,
onClick: () => {
setSelectedPlugin(record.pk);
deletePluginModal.open();
}
});
}
return actions;
},
[user, pluginsEnabled]
);
const [pluginPackage, setPluginPackage] = useState<string>('');
const installPluginModal = useCreateApiFormModal({
title: t`Install plugin`,
url: ApiEndpoints.plugin_install,
timeout: 30000,
fields: {
packagename: {},
url: {},
version: {},
confirm: {}
},
initialData: {
packagename: pluginPackage
},
closeOnClickOutside: false,
submitText: t`Install`,
successMessage: undefined,
@ -427,7 +509,48 @@ export function PluginListTable({ props }: { props: InvenTreeTableProps }) {
}
});
const user = useUserState();
const [selectedPlugin, setSelectedPlugin] = useState<number>(-1);
const uninstallPluginModal = useEditApiFormModal({
title: t`Uninstall Plugin`,
url: ApiEndpoints.plugin_uninstall,
pk: selectedPlugin,
fetchInitialData: false,
timeout: 30000,
fields: {
delete_config: {}
},
preFormContent: (
<Alert
color="red"
icon={<IconInfoCircle />}
title={t`Confirm plugin uninstall`}
>
<Stack spacing="xs">
<Text>{t`The selected plugin will be uninstalled.`}</Text>
<Text>{t`This action cannot be undone.`}</Text>
</Stack>
</Alert>
),
onFormSuccess: (data) => {
notifications.show({
title: t`Plugin uninstalled successfully`,
message: data.result,
autoClose: 30000,
color: 'green'
});
table.refreshTable();
}
});
const deletePluginModal = useDeleteApiFormModal({
url: ApiEndpoints.plugin_list,
pk: selectedPlugin,
title: t`Delete Plugin`,
onFormSuccess: table.refreshTable,
preFormWarning: t`Deleting this plugin configuration will remove all associated settings and data. Are you sure you want to delete this plugin?`
});
const reloadPlugins = useCallback(() => {
api
@ -465,7 +588,10 @@ export function PluginListTable({ props }: { props: InvenTreeTableProps }) {
color="green"
icon={<IconPlaylistAdd />}
tooltip={t`Install Plugin`}
onClick={() => installPluginModal.open()}
onClick={() => {
setPluginPackage('');
installPluginModal.open();
}}
/>
);
}
@ -476,9 +602,11 @@ export function PluginListTable({ props }: { props: InvenTreeTableProps }) {
return (
<>
{installPluginModal.modal}
{uninstallPluginModal.modal}
{deletePluginModal.modal}
<DetailDrawer
title={t`Plugin detail`}
size={'lg'}
size={'xl'}
renderContent={(id) => {
if (!id) return false;
return <PluginDrawer id={id} refreshTable={table.refreshTable} />;
@ -489,11 +617,7 @@ export function PluginListTable({ props }: { props: InvenTreeTableProps }) {
tableState={table}
columns={pluginTableColumns}
props={{
...props,
enableDownload: false,
params: {
...props.params
},
rowActions: rowActions,
onRowClick: (plugin) => navigate(`${plugin.pk}/`),
tableActions: tableActions,

View File

@ -52,9 +52,7 @@ export default function CustomUnitsTable() {
onFormSuccess: table.refreshTable
});
const [selectedUnit, setSelectedUnit] = useState<number | undefined>(
undefined
);
const [selectedUnit, setSelectedUnit] = useState<number>(-1);
const editUnit = useEditApiFormModal({
url: ApiEndpoints.custom_unit_list,

View File

@ -118,9 +118,7 @@ export function GroupTable() {
];
}, []);
const [selectedGroup, setSelectedGroup] = useState<number | undefined>(
undefined
);
const [selectedGroup, setSelectedGroup] = useState<number>(-1);
const deleteGroup = useDeleteApiFormModal({
url: ApiEndpoints.group_list,

View File

@ -98,9 +98,7 @@ export function StockLocationTable({ parentId }: { parentId?: any }) {
}
});
const [selectedLocation, setSelectedLocation] = useState<number | undefined>(
undefined
);
const [selectedLocation, setSelectedLocation] = useState<number>(-1);
const editLocation = useEditApiFormModal({
url: ApiEndpoints.stock_location_list,