2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-18 04:55:44 +00:00

[Feature] Part lock (#7527)

* Add "locked" field to Part model

- Default = false

* Add "locked" field to PartSerializer

- Allow filtering in API

* Filter CUI tables by "locked" status

* Add "locked" filter to part table

* Update PUI table

* PUI: Update display of part details  page

* Add "locked" element

* Ensmallen the gap

* Edit "locked" field in CUI

* Check BomItem before editing or deleting

* Prevent bulk delete of BOM items

* Check part lock for PartParameter model

* Prevent deletion of a locked part

* Add option to prevent build order creation for unlocked part

* Bump API version

* Hide actions from BOM table if part is locked

* Fix for boolean form field

* Update <PartParameterTable>

* Add unit test for 'BUILDORDER_REQUIRE_LOCKED_PART' setting

* Add unit test for part deletion

* add bom item test

* unit test for part parameter

* Update playwright tests

* Update docs

* Remove defunct setting

* Update playwright tests
This commit is contained in:
Oliver
2024-07-07 11:35:30 +10:00
committed by GitHub
parent 97b6258797
commit 8309eb628f
30 changed files with 448 additions and 119 deletions

View File

@ -24,15 +24,17 @@ export function PartIcons({ part }: { part: any }) {
return (
<td colSpan={2}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
{part.locked && (
<Tooltip label={t`Part is locked`}>
<Badge color="black" variant="filled">
<Trans>Locked</Trans>
</Badge>
</Tooltip>
)}
{!part.active && (
<Tooltip label={t`Part is not active`}>
<Badge color="red" variant="filled">
<div
style={{ display: 'flex', alignItems: 'center', gap: '5px' }}
>
<InvenTreeIcon icon="inactive" iconProps={{ size: 19 }} />{' '}
<Trans>Inactive</Trans>
</div>
<Trans>Inactive</Trans>
</Badge>
</Tooltip>
)}

View File

@ -192,8 +192,8 @@ export function ApiFormField({
}, [value]);
// Coerce the value to a (stringified) boolean value
const booleanValue: string = useMemo(() => {
return isTrue(value).toString();
const booleanValue: boolean = useMemo(() => {
return isTrue(value);
}, [value]);
// Construct the individual field
@ -232,13 +232,12 @@ export function ApiFormField({
return (
<Switch
{...reducedDefinition}
value={booleanValue}
checked={booleanValue}
ref={ref}
id={fieldId}
aria-label={`boolean-field-${field.name}`}
radius="lg"
size="sm"
checked={isTrue(reducedDefinition.value)}
error={error?.message}
onChange={(event) => onChange(event.currentTarget.checked)}
/>

View File

@ -46,6 +46,7 @@ export function usePartFields({
purchaseable: {},
salable: {},
virtual: {},
locked: {},
active: {}
};

View File

@ -38,6 +38,7 @@ import {
IconLink,
IconList,
IconListTree,
IconLock,
IconMail,
IconMapPin,
IconMapPinHeart,
@ -152,6 +153,8 @@ const icons = {
inactive: IconX,
part: IconBox,
supplier_part: IconPackageImport,
lock: IconLock,
locked: IconLock,
calendar: IconCalendar,
external: IconExternalLink,

View File

@ -246,6 +246,7 @@ export default function SystemSettings() {
'BUILDORDER_REFERENCE_PATTERN',
'BUILDORDER_REQUIRE_RESPONSIBLE',
'BUILDORDER_REQUIRE_ACTIVE_PART',
'BUILDORDER_REQUIRE_LOCKED_PART',
'BUILDORDER_REQUIRE_VALID_BOM',
'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS'
]}

View File

@ -260,6 +260,11 @@ export default function PartDetail() {
name: 'active',
label: t`Active`
},
{
type: 'boolean',
name: 'locked',
label: t`Locked`
},
{
type: 'boolean',
name: 'template',
@ -485,7 +490,12 @@ export default function PartDetail() {
name: 'parameters',
label: t`Parameters`,
icon: <IconList />,
content: <PartParameterTable partId={id ?? -1} />
content: (
<PartParameterTable
partId={id ?? -1}
partLocked={part?.locked == true}
/>
)
},
{
name: 'stock',
@ -520,7 +530,9 @@ export default function PartDetail() {
label: t`Bill of Materials`,
icon: <IconListTree />,
hidden: !part.assembly,
content: <BomTable partId={part.pk ?? -1} />
content: (
<BomTable partId={part.pk ?? -1} partLocked={part?.locked == true} />
)
},
{
name: 'builds',
@ -681,6 +693,12 @@ export default function PartDetail() {
visible={part.building > 0}
key="in_production"
/>,
<DetailsBadge
label={t`Locked`}
color="black"
visible={part.locked}
key="locked"
/>,
<DetailsBadge
label={t`Inactive`}
color="red"

View File

@ -2,7 +2,8 @@
* Common rendering functions for table column data.
*/
import { t } from '@lingui/macro';
import { Anchor, Skeleton, Text } from '@mantine/core';
import { Anchor, Group, Skeleton, Text, Tooltip } from '@mantine/core';
import { IconExclamationCircle, IconLock } from '@tabler/icons-react';
import { YesNoButton } from '../components/buttons/YesNoButton';
import { Thumbnail } from '../components/images/Thumbnail';
@ -19,10 +20,24 @@ import { ProjectCodeHoverCard } from './TableHoverCard';
// Render a Part instance within a table
export function PartColumn(part: any, full_name?: boolean) {
return part ? (
<Thumbnail
src={part?.thumbnail ?? part?.image}
text={full_name ? part?.full_name : part?.name}
/>
<Group justify="space-between" wrap="nowrap">
<Thumbnail
src={part?.thumbnail ?? part?.image}
text={full_name ? part?.full_name : part?.name}
/>
<Group justify="flex-end" wrap="nowrap" gap="xs">
{part?.active == false && (
<Tooltip label={t`Part is not active`}>
<IconExclamationCircle color="red" size={16} />
</Tooltip>
)}
{part?.locked && (
<Tooltip label={t`Part is locked`}>
<IconLock size={16} />
</Tooltip>
)}
</Group>
</Group>
) : (
<Skeleton />
);

View File

@ -1,9 +1,10 @@
import { t } from '@lingui/macro';
import { Group, Text } from '@mantine/core';
import { Alert, Group, Stack, Text } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import {
IconArrowRight,
IconCircleCheck,
IconLock,
IconSwitch3
} from '@tabler/icons-react';
import { ReactNode, useCallback, useMemo, useState } from 'react';
@ -56,9 +57,11 @@ function availableStockQuantity(record: any): number {
export function BomTable({
partId,
partLocked,
params = {}
}: {
partId: number;
partLocked?: boolean;
params?: any;
}) {
const user = useUserState();
@ -384,12 +387,15 @@ export function BomTable({
{
title: t`Validate BOM Line`,
color: 'green',
hidden: record.validated || !user.hasChangeRole(UserRoles.part),
hidden:
partLocked ||
record.validated ||
!user.hasChangeRole(UserRoles.part),
icon: <IconCircleCheck />,
onClick: () => validateBomItem(record)
},
RowEditAction({
hidden: !user.hasChangeRole(UserRoles.part),
hidden: partLocked || !user.hasChangeRole(UserRoles.part),
onClick: () => {
setSelectedBomItem(record.pk);
editBomItem.open();
@ -398,11 +404,11 @@ export function BomTable({
{
title: t`Edit Substitutes`,
color: 'blue',
hidden: !user.hasChangeRole(UserRoles.part),
hidden: partLocked || !user.hasChangeRole(UserRoles.part),
icon: <IconSwitch3 />
},
RowDeleteAction({
hidden: !user.hasDeleteRole(UserRoles.part),
hidden: partLocked || !user.hasDeleteRole(UserRoles.part),
onClick: () => {
setSelectedBomItem(record.pk);
deleteBomItem.open();
@ -410,44 +416,56 @@ export function BomTable({
})
];
},
[partId, user]
[partId, partLocked, user]
);
const tableActions = useMemo(() => {
return [
<AddItemButton
hidden={!user.hasAddRole(UserRoles.part)}
hidden={partLocked || !user.hasAddRole(UserRoles.part)}
tooltip={t`Add BOM Item`}
onClick={() => newBomItem.open()}
/>
];
}, [user]);
}, [partLocked, user]);
return (
<>
{newBomItem.modal}
{editBomItem.modal}
{deleteBomItem.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.bom_list)}
tableState={table}
columns={tableColumns}
props={{
params: {
...params,
part: partId,
part_detail: true,
sub_part_detail: true
},
tableActions: tableActions,
tableFilters: tableFilters,
modelType: ModelType.part,
modelField: 'sub_part',
rowActions: rowActions,
enableSelection: true,
enableBulkDelete: true
}}
/>
<Stack gap="xs">
{partLocked && (
<Alert
title={t`Part is Locked`}
color="red"
icon={<IconLock />}
p="xs"
>
<Text>{t`Bill of materials cannot be edited, as the part is locked`}</Text>
</Alert>
)}
<InvenTreeTable
url={apiUrl(ApiEndpoints.bom_list)}
tableState={table}
columns={tableColumns}
props={{
params: {
...params,
part: partId,
part_detail: true,
sub_part_detail: true
},
tableActions: tableActions,
tableFilters: tableFilters,
modelType: ModelType.part,
modelField: 'sub_part',
rowActions: rowActions,
enableSelection: !partLocked,
enableBulkDelete: !partLocked
}}
/>
</Stack>
</>
);
}

View File

@ -1,5 +1,6 @@
import { t } from '@lingui/macro';
import { Text } from '@mantine/core';
import { Alert, Stack, Text } from '@mantine/core';
import { IconLock } from '@tabler/icons-react';
import { useCallback, useMemo, useState } from 'react';
import { AddItemButton } from '../../components/buttons/AddItemButton';
@ -25,7 +26,13 @@ import { TableHoverCard } from '../TableHoverCard';
/**
* Construct a table listing parameters for a given part
*/
export function PartParameterTable({ partId }: { partId: any }) {
export function PartParameterTable({
partId,
partLocked
}: {
partId: any;
partLocked?: boolean;
}) {
const table = useTable('part-parameters');
const user = useUserState();
@ -142,7 +149,7 @@ export function PartParameterTable({ partId }: { partId: any }) {
return [
RowEditAction({
tooltip: t`Edit Part Parameter`,
hidden: !user.hasChangeRole(UserRoles.part),
hidden: partLocked || !user.hasChangeRole(UserRoles.part),
onClick: () => {
setSelectedParameter(record.pk);
editParameter.open();
@ -150,7 +157,7 @@ export function PartParameterTable({ partId }: { partId: any }) {
}),
RowDeleteAction({
tooltip: t`Delete Part Parameter`,
hidden: !user.hasDeleteRole(UserRoles.part),
hidden: partLocked || !user.hasDeleteRole(UserRoles.part),
onClick: () => {
setSelectedParameter(record.pk);
deleteParameter.open();
@ -158,7 +165,7 @@ export function PartParameterTable({ partId }: { partId: any }) {
})
];
},
[partId, user]
[partId, partLocked, user]
);
// Custom table actions
@ -166,40 +173,52 @@ export function PartParameterTable({ partId }: { partId: any }) {
return [
<AddItemButton
key="add-parameter"
hidden={!user.hasAddRole(UserRoles.part)}
hidden={partLocked || !user.hasAddRole(UserRoles.part)}
tooltip={t`Add parameter`}
onClick={() => newParameter.open()}
/>
];
}, [user]);
}, [partLocked, user]);
return (
<>
{newParameter.modal}
{editParameter.modal}
{deleteParameter.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.part_parameter_list)}
tableState={table}
columns={tableColumns}
props={{
rowActions: rowActions,
enableDownload: true,
tableActions: tableActions,
tableFilters: [
{
name: 'include_variants',
label: t`Include Variants`,
type: 'boolean'
<Stack gap="xs">
{partLocked && (
<Alert
title={t`Part is Locked`}
color="red"
icon={<IconLock />}
p="xs"
>
<Text>{t`Part parameters cannot be edited, as the part is locked`}</Text>
</Alert>
)}
<InvenTreeTable
url={apiUrl(ApiEndpoints.part_parameter_list)}
tableState={table}
columns={tableColumns}
props={{
rowActions: rowActions,
enableDownload: true,
tableActions: tableActions,
tableFilters: [
{
name: 'include_variants',
label: t`Include Variants`,
type: 'boolean'
}
],
params: {
part: partId,
template_detail: true,
part_detail: true
}
],
params: {
part: partId,
template_detail: true,
part_detail: true
}
}}
/>
}}
/>
</Stack>
</>
);
}

View File

@ -25,6 +25,7 @@ function partTableColumns(): TableColumn[] {
return [
{
accessor: 'name',
title: t`Part`,
sortable: true,
noWrap: true,
render: (record: any) => PartColumn(record)
@ -169,6 +170,12 @@ function partTableFilters(): TableFilter[] {
description: t`Filter by part active status`,
type: 'boolean'
},
{
name: 'locked',
label: t`Locked`,
description: t`Filter by part locked status`,
type: 'boolean'
},
{
name: 'assembly',
label: t`Assembly`,

View File

@ -2,6 +2,27 @@ import { test } from '../baseFixtures';
import { baseUrl } from '../defaults';
import { doQuickLogin } from '../login';
test('PUI - Pages - Part - Locking', async ({ page }) => {
await doQuickLogin(page);
// Navigate to a known assembly which is *not* locked
await page.goto(`${baseUrl}/part/104/bom`);
await page.getByRole('tab', { name: 'Bill of Materials' }).click();
await page.getByLabel('action-button-add-bom-item').waitFor();
await page.getByRole('tab', { name: 'Parameters' }).click();
await page.getByLabel('action-button-add-parameter').waitFor();
// Navigate to a known assembly which *is* locked
await page.goto(`${baseUrl}/part/100/bom`);
await page.getByRole('tab', { name: 'Bill of Materials' }).click();
await page.getByText('Locked', { exact: true }).waitFor();
await page.getByText('Part is Locked', { exact: true }).waitFor();
// Check the "parameters" tab also
await page.getByRole('tab', { name: 'Parameters' }).click();
await page.getByText('Part parameters cannot be').waitFor();
});
test('PUI - Pages - Part - Pricing (Nothing, BOM)', async ({ page }) => {
await doQuickLogin(page);