2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-17 04:25:42 +00:00

Barcode logging (#8150)

* Add model for recording barcode scan results

* Add "admin" interface for new model

* Add API endpoints for barcode scan history

* Add global setting to control barcode result save

* Add frontend API endpoint

* Add PUI table in "admin center"

* Add API filter class

* Enable table filtering

* Update model definition

* Allow more characters for barcode log

* Log results to server

* Add setting to control how long results are stored

* Table updates

* Add background task to delete old barcode scans

* Add detail drawer for barcode scan

* Log messages for BarcodePOReceive

* Add warning message if barcode logging is not enabled

* Add "context" data to BarcodeScanResult

* Display context data (if available)

* Add context data when scanning

* Simplify / refactor BarcodeSOAllocate

* Refactor BarcodePOAllocate

* Limit the number of saved scans

* Improve error message display in PUI

* Simplify barcode logging

* Improve table

* Updates

* Settings page fix

* Fix panel tooltips

* Adjust table

* Add "result" field

* Refactor calls to "log_scan"

* Display result in PUI table

* Updates

* Fix typo

* Update unit test

* Improve exception handling

* Unit test updates

* Enhanced unit test

* Ensure all database key config values are upper case

* Refactor some playwright helpers

* Adds playwright test for barcode scan history table

* Requires some timeout

* Add docs
This commit is contained in:
Oliver
2024-09-23 23:30:50 +10:00
committed by GitHub
parent f7e0edb7a6
commit 6002103129
28 changed files with 929 additions and 168 deletions

View File

@ -3,6 +3,7 @@ import {
ActionIcon,
Button,
CopyButton as MantineCopyButton,
MantineSize,
Text,
Tooltip
} from '@mantine/core';
@ -11,10 +12,14 @@ import { InvenTreeIcon } from '../../functions/icons';
export function CopyButton({
value,
label
label,
content,
size
}: Readonly<{
value: any;
label?: JSX.Element;
label?: string;
content?: JSX.Element;
size?: MantineSize;
}>) {
const ButtonComponent = label ? Button : ActionIcon;
@ -26,15 +31,19 @@ export function CopyButton({
color={copied ? 'teal' : 'gray'}
onClick={copy}
variant="transparent"
size="sm"
size={size ?? 'sm'}
>
{copied ? (
<InvenTreeIcon icon="check" />
) : (
<InvenTreeIcon icon="copy" />
)}
{label && <Text ml={10}>{label}</Text>}
{content}
{label && (
<Text p={size ?? 'sm'} size={size ?? 'sm'}>
{label}
</Text>
)}
</ButtonComponent>
</Tooltip>
)}

View File

@ -75,7 +75,7 @@ export const InvenTreeQRCode = ({
const { data } = useQuery({
queryKey: ['qr-code', mdl_prop.model, mdl_prop.pk],
queryFn: async () => {
const res = await api.post(apiUrl(ApiEndpoints.generate_barcode), {
const res = await api.post(apiUrl(ApiEndpoints.barcode_generate), {
model: mdl_prop.model,
pk: mdl_prop.pk
});

View File

@ -1,4 +1,4 @@
import { Trans } from '@lingui/macro';
import { Trans, t } from '@lingui/macro';
import {
Anchor,
Badge,
@ -178,10 +178,7 @@ export function AboutInvenTreeModal({
</Table>
<Divider />
<Group justify="space-between">
<CopyButton
value={copyval}
label={<Trans>Copy version information</Trans>}
/>
<CopyButton value={copyval} label={t`Copy version information`} />
<Space />
<Button
color="red"

View File

@ -134,7 +134,7 @@ function BasePanelGroup({
(panel) =>
!panel.hidden && (
<Tooltip
label={`tooltip-${panel.name}`}
label={panel.label ?? panel.name}
key={panel.name}
disabled={expanded}
position="right"

View File

@ -39,10 +39,6 @@ export enum ApiEndpoints {
api_search = 'search/',
settings_global_list = 'settings/global/',
settings_user_list = 'settings/user/',
barcode = 'barcode/',
barcode_link = 'barcode/link/',
barcode_unlink = 'barcode/unlink/',
generate_barcode = 'barcode/generate/',
news = 'news/',
global_status = 'generic/status/',
custom_state_list = 'generic/status/custom/',
@ -54,6 +50,13 @@ export enum ApiEndpoints {
content_type_list = 'contenttype/',
icons = 'icons/',
// Barcode API endpoints
barcode = 'barcode/',
barcode_history = 'barcode/history/',
barcode_link = 'barcode/link/',
barcode_unlink = 'barcode/unlink/',
barcode_generate = 'barcode/generate/',
// Data import endpoints
import_session_list = 'importer/session/',
import_session_accept_fields = 'importer/session/:id/accept_fields/',

View File

@ -22,5 +22,5 @@ export function shortenString({
// Otherwise, shorten it
let N = Math.floor(len / 2 - 1);
return str.slice(0, N) + '...' + str.slice(-N);
return str.slice(0, N) + ' ... ' + str.slice(-N);
}

View File

@ -19,6 +19,7 @@ import {
IconListDetails,
IconPackages,
IconPlugConnected,
IconQrcode,
IconReport,
IconScale,
IconSitemap,
@ -68,6 +69,10 @@ const ErrorReportTable = Loadable(
lazy(() => import('../../../../tables/settings/ErrorTable'))
);
const BarcodeScanHistoryTable = Loadable(
lazy(() => import('../../../../tables/settings/BarcodeScanHistoryTable'))
);
const ImportSesssionTable = Loadable(
lazy(() => import('../../../../tables/settings/ImportSessionTable'))
);
@ -111,6 +116,12 @@ export default function AdminCenter() {
icon: <IconFileUpload />,
content: <ImportSesssionTable />
},
{
name: 'barcode-history',
label: t`Barcode Scans`,
icon: <IconQrcode />,
content: <BarcodeScanHistoryTable />
},
{
name: 'background',
label: t`Background Tasks`,

View File

@ -98,7 +98,9 @@ export default function SystemSettings() {
'BARCODE_INPUT_DELAY',
'BARCODE_WEBCAM_SUPPORT',
'BARCODE_SHOW_TEXT',
'BARCODE_GENERATION_PLUGIN'
'BARCODE_GENERATION_PLUGIN',
'BARCODE_STORE_RESULTS',
'BARCODE_RESULTS_MAX_NUM'
]}
/>
)

View File

@ -243,6 +243,10 @@ export function InvenTreeTable<T extends Record<string, any>>({
};
}, [props]);
const enableSelection: boolean = useMemo(() => {
return tableProps.enableSelection || tableProps.enableBulkDelete || false;
}, [tableProps]);
// Check if any columns are switchable (can be hidden)
const hasSwitchableColumns: boolean = useMemo(() => {
if (props.enableColumnSwitching == false) {
@ -309,7 +313,6 @@ export function InvenTreeTable<T extends Record<string, any>>({
columns,
fieldNames,
tableProps.rowActions,
tableProps.enableSelection,
tableState.hiddenColumns,
tableState.selectedRecords
]);
@ -641,7 +644,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
actions={tableProps.barcodeActions ?? []}
/>
)}
{(tableProps.enableBulkDelete ?? false) && (
{tableProps.enableBulkDelete && (
<ActionButton
disabled={!tableState.hasSelectedRecords}
icon={<IconTrash />}
@ -726,12 +729,10 @@ export function InvenTreeTable<T extends Record<string, any>>({
sortStatus={sortStatus}
onSortStatusChange={handleSortStatusChange}
selectedRecords={
tableProps.enableSelection
? tableState.selectedRecords
: undefined
enableSelection ? tableState.selectedRecords : undefined
}
onSelectedRecordsChange={
tableProps.enableSelection ? onSelectedRecordsChange : undefined
enableSelection ? onSelectedRecordsChange : undefined
}
rowExpansion={tableProps.rowExpansion}
rowStyle={tableProps.rowStyle}

View File

@ -0,0 +1,290 @@
import { t } from '@lingui/macro';
import {
Alert,
Badge,
Divider,
Drawer,
Group,
MantineStyleProp,
Stack,
Table,
Text
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconExclamationCircle } from '@tabler/icons-react';
import { useCallback, useMemo, useState } from 'react';
import { CopyButton } from '../../components/buttons/CopyButton';
import { PassFailButton } from '../../components/buttons/YesNoButton';
import { StylishText } from '../../components/items/StylishText';
import { RenderUser } from '../../components/render/User';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { UserRoles } from '../../enums/Roles';
import { shortenString } from '../../functions/tables';
import { useUserFilters } from '../../hooks/UseFilter';
import { useDeleteApiFormModal } from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useGlobalSettingsState } from '../../states/SettingsState';
import { useUserState } from '../../states/UserState';
import { TableColumn } from '../Column';
import { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowDeleteAction } from '../RowActions';
/*
* Render detail information for a particular barcode scan result.
*/
function BarcodeScanDetail({ scan }: { scan: any }) {
const dataStyle: MantineStyleProp = {
textWrap: 'wrap',
lineBreak: 'auto',
wordBreak: 'break-word'
};
const hasResponseData = useMemo(() => {
return scan.response && Object.keys(scan.response).length > 0;
}, [scan.response]);
const hasContextData = useMemo(() => {
return scan.context && Object.keys(scan.context).length > 0;
}, [scan.context]);
return (
<>
<Stack gap="xs">
<Divider />
<Table>
<Table.Tbody>
<Table.Tr>
<Table.Td colSpan={2}>
<StylishText size="sm">{t`Barcode Information`}</StylishText>
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>{t`Barcode`}</Table.Th>
<Table.Td>
<Text size="sm" style={dataStyle}>
{scan.data}
</Text>
</Table.Td>
<Table.Td>
<CopyButton value={scan.data} size="xs" />
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>{t`Timestamp`}</Table.Th>
<Table.Td>{scan.timestamp}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>{t`User`}</Table.Th>
<Table.Td>
<RenderUser instance={scan.user_detail} />
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>{t`Endpoint`}</Table.Th>
<Table.Td>{scan.endpoint}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>{t`Result`}</Table.Th>
<Table.Td>
<PassFailButton value={scan.result} />
</Table.Td>
</Table.Tr>
{hasContextData && (
<Table.Tr>
<Table.Td colSpan={2}>
<StylishText size="sm">{t`Context`}</StylishText>
</Table.Td>
</Table.Tr>
)}
{hasContextData &&
Object.keys(scan.context).map((key) => (
<Table.Tr key={key}>
<Table.Th>{key}</Table.Th>
<Table.Td>
<Text size="sm" style={dataStyle}>
{scan.context[key]}
</Text>
</Table.Td>
<Table.Td>
<CopyButton value={scan.context[key]} size="xs" />
</Table.Td>
</Table.Tr>
))}
{hasResponseData && (
<Table.Tr>
<Table.Td colSpan={2}>
<StylishText size="sm">{t`Response`}</StylishText>
</Table.Td>
</Table.Tr>
)}
{hasResponseData &&
Object.keys(scan.response).map((key) => (
<Table.Tr key={key}>
<Table.Th>{key}</Table.Th>
<Table.Td>
<Text size="sm" style={dataStyle}>
{scan.response[key]}
</Text>
</Table.Td>
<Table.Td>
<CopyButton value={scan.response[key]} size="xs" />
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
</>
);
}
/*
* Display the barcode scan history table
*/
export default function BarcodeScanHistoryTable() {
const user = useUserState();
const table = useTable('barcode-history');
const globalSettings = useGlobalSettingsState();
const userFilters = useUserFilters();
const [opened, { open, close }] = useDisclosure(false);
const tableColumns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'timestamp',
sortable: true,
switchable: false,
render: (record: any) => {
return (
<Group justify="space-between" wrap="nowrap">
<Text>{record.timestamp}</Text>
{record.user_detail && (
<Badge size="xs">{record.user_detail.username}</Badge>
)}
</Group>
);
}
},
{
accessor: 'data',
sortable: false,
switchable: true,
render: (record: any) => {
return (
<Text
size="xs"
style={{
textWrap: 'wrap',
lineBreak: 'auto',
wordBreak: 'break-word'
}}
>
{shortenString({ str: record.data, len: 100 })}
</Text>
);
}
},
{
accessor: 'endpoint',
sortable: true
},
{
accessor: 'result',
sortable: true,
render: (record: any) => {
return <PassFailButton value={record.result} />;
}
}
];
}, []);
const filters: TableFilter[] = useMemo(() => {
return [
{
name: 'user',
label: t`User`,
choices: userFilters.choices,
description: t`Filter by user`
},
{
name: 'result',
label: t`Result`,
description: t`Filter by result`
}
];
}, [userFilters]);
const canDelete: boolean = useMemo(() => {
return user.isStaff() && user.hasDeleteRole(UserRoles.admin);
}, [user]);
const [selectedResult, setSelectedResult] = useState<any>({});
const deleteResult = useDeleteApiFormModal({
url: ApiEndpoints.barcode_history,
pk: selectedResult.pk,
title: t`Delete Barcode Scan Record`,
table: table
});
const rowActions = useCallback(
(record: any) => {
return [
RowDeleteAction({
hidden: !canDelete,
onClick: () => {
setSelectedResult(record);
deleteResult.open();
}
})
];
},
[canDelete, user]
);
return (
<>
{deleteResult.modal}
<Drawer
opened={opened}
size="xl"
position="right"
title={<StylishText>{t`Barcode Scan Details`}</StylishText>}
onClose={close}
>
<BarcodeScanDetail scan={selectedResult} />
</Drawer>
<Stack gap="xs">
{!globalSettings.isSet('BARCODE_STORE_RESULTS') && (
<Alert
color="red"
icon={<IconExclamationCircle />}
title={t`Logging Disabled`}
>
<Text>{t`Barcode logging is not enabled`}</Text>
</Alert>
)}
<InvenTreeTable
url={apiUrl(ApiEndpoints.barcode_history)}
tableState={table}
columns={tableColumns}
props={{
tableFilters: filters,
enableBulkDelete: canDelete,
rowActions: rowActions,
onRowClick: (row) => {
setSelectedResult(row);
open();
}
}}
/>
</Stack>
</>
);
}

View File

@ -1,8 +1,9 @@
import { t } from '@lingui/macro';
import { Drawer, Text } from '@mantine/core';
import { Drawer, Group, Stack, Table, Text } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useCallback, useMemo, useState } from 'react';
import { CopyButton } from '../../components/buttons/CopyButton';
import { StylishText } from '../../components/items/StylishText';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { useDeleteApiFormModal } from '../../hooks/UseForm';
@ -13,6 +14,48 @@ import { TableColumn } from '../Column';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowAction, RowDeleteAction } from '../RowActions';
function ErrorDetail({ error }: { error: any }) {
return (
<Stack gap="xs">
<Table>
<Table.Tbody>
<Table.Tr>
<Table.Th>{t`Message`}</Table.Th>
<Table.Td>{error.info}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>{t`Timestamp`}</Table.Th>
<Table.Td>{error.when}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>{t`Path`}</Table.Th>
<Table.Td>{error.path}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>{t`Traceback`}</Table.Th>
<Table.Td>
<Group justify="right">
<CopyButton value={error.data} size="sm" />
</Group>
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td colSpan={2}>
<Stack gap={3}>
{error.data.split('\n').map((line: string, index: number) => (
<Text size="xs" key={`error-line-${index}`}>
{line}
</Text>
))}
</Stack>
</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
</Stack>
);
}
/*
* Table for display server error information
*/
@ -20,8 +63,6 @@ export default function ErrorReportTable() {
const table = useTable('error-report');
const user = useUserState();
const [error, setError] = useState<string>('');
const [opened, { open, close }] = useDisclosure(false);
const columns: TableColumn[] = useMemo(() => {
@ -43,13 +84,11 @@ export default function ErrorReportTable() {
];
}, []);
const [selectedError, setSelectedError] = useState<number | undefined>(
undefined
);
const [selectedError, setSelectedError] = useState<any>({});
const deleteErrorModal = useDeleteApiFormModal({
url: ApiEndpoints.error_report_list,
pk: selectedError,
pk: selectedError.pk,
title: t`Delete Error Report`,
preFormContent: (
<Text c="red">{t`Are you sure you want to delete this error report?`}</Text>
@ -62,7 +101,7 @@ export default function ErrorReportTable() {
return [
RowDeleteAction({
onClick: () => {
setSelectedError(record.pk);
setSelectedError(record);
deleteErrorModal.open();
}
})
@ -79,13 +118,7 @@ export default function ErrorReportTable() {
title={<StylishText>{t`Error Details`}</StylishText>}
onClose={close}
>
{error.split('\n').map((line: string) => {
return (
<Text key={line} size="sm">
{line}
</Text>
);
})}
<ErrorDetail error={selectedError} />
</Drawer>
<InvenTreeTable
url={apiUrl(ApiEndpoints.error_report_list)}
@ -96,7 +129,7 @@ export default function ErrorReportTable() {
enableSelection: true,
rowActions: rowActions,
onRowClick: (row) => {
setError(row.data);
setSelectedError(row);
open();
}
}}

View File

@ -1,5 +1,6 @@
export const classicUrl = 'http://127.0.0.1:8000';
export const apiUrl = `${classicUrl}/api`;
export const baseUrl = './platform';
export const loginUrl = `${baseUrl}/login`;
export const logoutUrl = `${baseUrl}/logout`;

View File

@ -1,58 +1,8 @@
import test, { expect } from 'playwright/test';
import test from 'playwright/test';
import { baseUrl } from './defaults.js';
import { doQuickLogin } from './login.js';
/*
* Set the value of a global setting in the database
*/
const setSettingState = async ({
request,
setting,
value
}: {
request: any;
setting: string;
value: any;
}) => {
const url = `http://localhost:8000/api/settings/global/${setting}/`;
const response = await request.patch(url, {
data: {
value: value
},
headers: {
// Basic username: password authorization
Authorization: `Basic ${btoa('admin:inventree')}`
}
});
expect(await response.status()).toBe(200);
};
const setPluginState = async ({
request,
plugin,
state
}: {
request: any;
plugin: string;
state: boolean;
}) => {
const url = `http://localhost:8000/api/plugins/${plugin}/activate/`;
const response = await request.patch(url, {
data: {
active: state
},
headers: {
// Basic username: password authorization
Authorization: `Basic ${btoa('admin:inventree')}`
}
});
expect(await response.status()).toBe(200);
};
import { setPluginState, setSettingState } from './settings.js';
test('Plugins - Panels', async ({ page, request }) => {
await doQuickLogin(page, 'admin', 'inventree');

View File

@ -1,6 +1,7 @@
import { expect, test } from './baseFixtures.js';
import { baseUrl } from './defaults.js';
import { apiUrl, baseUrl } from './defaults.js';
import { doQuickLogin } from './login.js';
import { setSettingState } from './settings.js';
test('PUI - Admin', async ({ page }) => {
// Note here we login with admin access
@ -85,6 +86,43 @@ test('PUI - Admin', async ({ page }) => {
await page.getByRole('button', { name: 'Submit' }).click();
});
test('PUI - Admin - Barcode History', async ({ page, request }) => {
// Login with admin credentials
await doQuickLogin(page, 'admin', 'inventree');
// Ensure that the "save scans" setting is enabled
await setSettingState({
request: request,
setting: 'BARCODE_STORE_RESULTS',
value: true
});
// Scan some barcodes (via API calls)
const barcodes = ['ABC1234', 'XYZ5678', 'QRS9012'];
barcodes.forEach(async (barcode) => {
await request.post(`${apiUrl}/barcode/`, {
data: {
barcode: barcode
},
headers: {
Authorization: `Basic ${btoa('admin:inventree')}`
}
});
});
await page.getByRole('button', { name: 'admin' }).click();
await page.getByRole('menuitem', { name: 'Admin Center' }).click();
await page.getByRole('tab', { name: 'Barcode Scans' }).click();
await page.waitForTimeout(2000);
// Barcode history is displayed in table
barcodes.forEach(async (barcode) => {
await page.getByText(barcode).first().waitFor();
});
});
test('PUI - Admin - Unauthorized', async ({ page }) => {
// Try to access "admin" page with a non-staff user
await doQuickLogin(page, 'allaccess', 'nolimits');

View File

@ -0,0 +1,54 @@
import { expect } from 'playwright/test';
import { apiUrl } from './defaults';
/*
* Set the value of a global setting in the database
*/
export const setSettingState = async ({
request,
setting,
value
}: {
request: any;
setting: string;
value: any;
}) => {
const url = `${apiUrl}/settings/global/${setting}/`;
const response = await request.patch(url, {
data: {
value: value
},
headers: {
// Basic username: password authorization
Authorization: `Basic ${btoa('admin:inventree')}`
}
});
expect(await response.status()).toBe(200);
};
export const setPluginState = async ({
request,
plugin,
state
}: {
request: any;
plugin: string;
state: boolean;
}) => {
const url = `${apiUrl}/plugins/${plugin}/activate/`;
const response = await request.patch(url, {
data: {
active: state
},
headers: {
// Basic username: password authorization
Authorization: `Basic ${btoa('admin:inventree')}`
}
});
expect(await response.status()).toBe(200);
};