[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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 448 additions and 119 deletions

View File

@ -246,3 +246,19 @@ Build orders may (optionally) have a target complete date specified. If this dat
- Builds can be filtered by overdue status in the build list
- Overdue builds will be displayed on the home page
## Build Order Restrictions
There are a number of optional restrictions which can be applied to build orders, which may be enabled or disabled in the system settings:
### Require Active Part
If this option is enabled, build orders can only be created for parts which are marked as [Active](../part/part.md#active-parts).
### Require Locked Part
If this option is enabled, build orders can only be created for parts which are marked as [Locked](../part/part.md#locked-parts).
### Require Valid BOM
If this option is enabled, build orders can only be created for parts which have a valid [Bill of Materials](./bom.md) defined.

View File

@ -73,7 +73,15 @@ A [Purchase Order](../order/purchase_order.md) allows parts to be ordered from a
If a part is designated as *Salable* it can be sold to external customers. Setting this flag allows parts to be added to sales orders.
### Active
## Locked Parts
Parts can be locked to prevent them from being modified. This is useful for parts which are in production and should not be changed. The following restrictions apply to parts which are locked:
- Locked parts cannot be deleted
- BOM items cannot be created, edited, or deleted when they are part of a locked assembly
- Part parameters linked to a locked part cannot be created, edited or deleted
## Active Parts
By default, all parts are *Active*. Marking a part as inactive means it is not available for many actions, but the part remains in the database. If a part becomes obsolete, it is recommended that it is marked as inactive, rather than deleting it from the database.

View File

@ -1,11 +1,15 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 212
INVENTREE_API_VERSION = 213
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v213 - 2024-07-06 : https://github.com/inventree/InvenTree/pull/7527
- Adds 'locked' field to Part API
v212 - 2024-07-06 : https://github.com/inventree/InvenTree/pull/7562
- Makes API generation more robust (no functional changes)

View File

@ -216,7 +216,7 @@ class MetadataMixin(models.Model):
self.save()
class DataImportMixin(object):
class DataImportMixin:
"""Model mixin class which provides support for 'data import' functionality.
Models which implement this mixin should provide information on the fields available for import

View File

@ -131,7 +131,14 @@ class Build(
# Check that the part is active
if not self.part.active:
raise ValidationError({
'part': _('Part is not active')
'part': _('Build order cannot be created for an inactive part')
})
if get_global_setting('BUILDORDER_REQUIRE_LOCKED_PART'):
# Check that the part is locked
if not self.part.locked:
raise ValidationError({
'part': _('Build order cannot be created for an unlocked part')
})
# On first save (i.e. creation), run some extra checks

View File

@ -99,6 +99,10 @@ class BuildTestSimple(InvenTreeTestCase):
# Find an assembly part
assembly = Part.objects.filter(assembly=True).first()
assembly.active = True
assembly.locked = False
assembly.save()
self.assertEqual(assembly.get_bom_items().count(), 0)
# Let's create some BOM items for this assembly
@ -121,6 +125,7 @@ class BuildTestSimple(InvenTreeTestCase):
# Create a build for an assembly with an *invalid* BOM
set_global_setting('BUILDORDER_REQUIRE_VALID_BOM', False)
set_global_setting('BUILDORDER_REQUIRE_ACTIVE_PART', True)
set_global_setting('BUILDORDER_REQUIRE_LOCKED_PART', False)
bo = Build.objects.create(part=assembly, quantity=10, reference='BO-9990')
bo.save()
@ -147,8 +152,18 @@ class BuildTestSimple(InvenTreeTestCase):
set_global_setting('BUILDORDER_REQUIRE_ACTIVE_PART', False)
Build.objects.create(part=assembly, quantity=10, reference='BO-9994')
# Check that the "locked" requirement works
set_global_setting('BUILDORDER_REQUIRE_LOCKED_PART', True)
with self.assertRaises(ValidationError):
Build.objects.create(part=assembly, quantity=10, reference='BO-9995')
assembly.locked = True
assembly.save()
Build.objects.create(part=assembly, quantity=10, reference='BO-9996')
# Check that expected quantity of new builds is created
self.assertEqual(Build.objects.count(), n + 3)
self.assertEqual(Build.objects.count(), n + 4)
class TestBuildViews(InvenTreeTestCase):
"""Tests for Build app views."""

View File

@ -1797,6 +1797,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': False,
'validator': bool,
},
'BUILDORDER_REQUIRE_LOCKED_PART': {
'name': _('Require Locked Part'),
'description': _('Prevent build order creation for unlocked parts'),
'default': False,
'validator': bool,
},
'BUILDORDER_REQUIRE_VALID_BOM': {
'name': _('Require Valid BOM'),
'description': _(
@ -2485,36 +2491,6 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'validator': [int, MinValueValidator(0)],
'default': 100,
},
'DEFAULT_PART_LABEL_TEMPLATE': {
'name': _('Default part label template'),
'description': _('The part label template to be automatically selected'),
'validator': [int],
'default': '',
},
'DEFAULT_ITEM_LABEL_TEMPLATE': {
'name': _('Default stock item template'),
'description': _(
'The stock item label template to be automatically selected'
),
'validator': [int],
'default': '',
},
'DEFAULT_LOCATION_LABEL_TEMPLATE': {
'name': _('Default stock location label template'),
'description': _(
'The stock location label template to be automatically selected'
),
'validator': [int],
'default': '',
},
'DEFAULT_LINE_LABEL_TEMPLATE': {
'name': _('Default build line label template'),
'description': _(
'The build line label template to be automatically selected'
),
'validator': [int],
'default': '',
},
'NOTIFICATION_ERROR_REPORT': {
'name': _('Receive error reports'),
'description': _('Receive notifications for system errors'),

View File

@ -1144,6 +1144,8 @@ class PartFilter(rest_filters.FilterSet):
active = rest_filters.BooleanFilter()
locked = rest_filters.BooleanFilter()
virtual = rest_filters.BooleanFilter()
tags_name = rest_filters.CharFilter(field_name='tags__name', lookup_expr='iexact')
@ -1873,6 +1875,14 @@ class BomList(BomMixin, DataExportViewMixin, ListCreateDestroyAPIView):
'pricing_updated': 'sub_part__pricing_data__updated',
}
def filter_delete_queryset(self, queryset, request):
"""Ensure that there are no 'locked' items."""
for bom_item in queryset:
# Note: Calling check_part_lock may raise a ValidationError
bom_item.check_part_lock(bom_item.part)
return super().filter_delete_queryset(queryset, request)
class BomDetail(BomMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a single BomItem object."""

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.12 on 2024-06-27 01:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0124_delete_partattachment'),
]
operations = [
migrations.AddField(
model_name='part',
name='locked',
field=models.BooleanField(default=False, help_text='Locked parts cannot be edited', verbose_name='Locked'),
),
]

View File

@ -377,6 +377,7 @@ class Part(
purchaseable: Can this part be purchased from suppliers?
trackable: Trackable parts can have unique serial numbers assigned, etc, etc
active: Is this part active? Parts are deactivated instead of being deleted
locked: This part is locked and cannot be edited
virtual: Is this part "virtual"? e.g. a software product or similar
notes: Additional notes field for this part
creation_date: Date that this part was added to the database
@ -481,6 +482,9 @@ class Part(
- The part is still active
- The part is used in a BOM for a different part.
"""
if self.locked:
raise ValidationError(_('Cannot delete this part as it is locked'))
if self.active:
raise ValidationError(_('Cannot delete this part as it is still active'))
@ -1081,6 +1085,12 @@ class Part(
default=True, verbose_name=_('Active'), help_text=_('Is this part active?')
)
locked = models.BooleanField(
default=False,
verbose_name=_('Locked'),
help_text=_('Locked parts cannot be edited'),
)
virtual = models.BooleanField(
default=part_settings.part_virtual_default,
verbose_name=_('Virtual'),
@ -3723,11 +3733,29 @@ class PartParameter(InvenTree.models.InvenTreeMetadataModel):
"""String representation of a PartParameter (used in the admin interface)."""
return f'{self.part.full_name} : {self.template.name} = {self.data} ({self.template.units})'
def delete(self):
"""Custom delete handler for the PartParameter model.
- Check if the parameter can be deleted
"""
self.check_part_lock()
super().delete()
def check_part_lock(self):
"""Check if the referenced part is locked."""
# TODO: Potentially control this behaviour via a global setting
if self.part.locked:
raise ValidationError(_('Parameter cannot be modified - part is locked'))
def save(self, *args, **kwargs):
"""Custom save method for the PartParameter model."""
# Validate the PartParameter before saving
self.calculate_numeric_value()
# Check if the part is locked
self.check_part_lock()
# Convert 'boolean' values to 'True' / 'False'
if self.template.checkbox:
self.data = str2bool(self.data)
@ -4037,15 +4065,53 @@ class BomItem(
"""
return Q(part__in=self.get_valid_parts_for_allocation())
def delete(self):
"""Check if this item can be deleted."""
self.check_part_lock(self.part)
super().delete()
def save(self, *args, **kwargs):
"""Enforce 'clean' operation when saving a BomItem instance."""
self.clean()
self.check_part_lock(self.part)
# Check if the part was changed
deltas = self.get_field_deltas()
if 'part' in deltas:
if old_part := deltas['part'].get('old', None):
self.check_part_lock(old_part)
# Update the 'validated' field based on checksum calculation
self.validated = self.is_line_valid
super().save(*args, **kwargs)
def check_part_lock(self, assembly):
"""When editing or deleting a BOM item, check if the assembly is locked.
If locked, raise an exception.
Arguments:
assembly: The assembly part
Raises:
ValidationError: If the assembly is locked
"""
# TODO: Perhaps control this with a global setting?
msg = _('BOM item cannot be modified - assembly is locked')
if assembly.locked:
raise ValidationError(msg)
# If this BOM item is inherited, check all variants of the assembly
if self.inherited:
for part in assembly.get_descendants(include_self=False):
if part.locked:
raise ValidationError(msg)
# A link to the parent part
# Each part will get a reverse lookup field 'bom_items'
part = models.ForeignKey(

View File

@ -632,6 +632,7 @@ class PartSerializer(
'keywords',
'last_stocktake',
'link',
'locked',
'minimum_stock',
'name',
'notes',

View File

@ -303,3 +303,50 @@ class BomItemTest(TestCase):
with self.assertRaises(django_exceptions.ValidationError):
BomItem.objects.create(part=part_v, sub_part=part_a, quantity=10)
def test_locked_assembly(self):
"""Test that BomItem objects work correctly for a 'locked' assembly."""
assembly = Part.objects.create(
name='Assembly2', description='An assembly part', assembly=True
)
sub_part = Part.objects.create(
name='SubPart1', description='A sub-part', component=True
)
# Initially, the assembly is not locked
self.assertFalse(assembly.locked)
# Create a BOM item for the assembly
bom_item = BomItem.objects.create(part=assembly, sub_part=sub_part, quantity=1)
# Lock the assembly
assembly.locked = True
assembly.save()
# Try to edit the BOM item
with self.assertRaises(django_exceptions.ValidationError):
bom_item.quantity = 10
bom_item.save()
# Try to delete the BOM item
with self.assertRaises(django_exceptions.ValidationError):
bom_item.delete()
# Try to create a new BOM item
with self.assertRaises(django_exceptions.ValidationError):
BomItem.objects.create(part=assembly, sub_part=sub_part, quantity=1)
# Unlock the part and try again
assembly.locked = False
assembly.save()
# Create a new BOM item
bom_item = BomItem.objects.create(part=assembly, sub_part=sub_part, quantity=1)
# Edit the new BOM item
bom_item.quantity = 10
bom_item.save()
# Delete the new BOM item
bom_item.delete()

View File

@ -77,6 +77,43 @@ class TestParams(TestCase):
param = prt.get_parameter('Not a parameter')
self.assertIsNone(param)
def test_locked_part(self):
"""Test parameter editing for a locked part."""
part = Part.objects.create(
name='Test Part 3',
description='A part for testing',
category=PartCategory.objects.first(),
IPN='TEST-PART',
)
parameter = PartParameter.objects.create(
part=part, template=PartParameterTemplate.objects.first(), data='123'
)
# Lock the part
part.locked = True
part.save()
# Attempt to edit the parameter
with self.assertRaises(django_exceptions.ValidationError):
parameter.data = '456'
parameter.save()
# Attempt to delete the parameter
with self.assertRaises(django_exceptions.ValidationError):
parameter.delete()
# Unlock the part
part.locked = False
part.save()
# Now we can edit the parameter
parameter.data = '456'
parameter.save()
# And we can delete the parameter
parameter.delete()
class TestCategoryTemplates(TransactionTestCase):
"""Test class for PartCategoryParameterTemplate model."""

View File

@ -371,6 +371,24 @@ class PartTest(TestCase):
self.assertIsNotNone(p.last_stocktake)
self.assertEqual(p.last_stocktake, ps.date)
def test_delete(self):
"""Test delete operation for a Part instance."""
part = Part.objects.first()
for active, locked in [(True, True), (True, False), (False, True)]:
# Cannot delete part if it is active or locked
part.active = active
part.locked = locked
part.save()
with self.assertRaises(ValidationError):
part.delete()
part.active = False
part.locked = False
part.delete()
class TestTemplateTest(TestCase):
"""Unit test for the TestTemplate class."""

View File

@ -15,6 +15,7 @@
{% include "InvenTree/settings/setting.html" with key="BUILDORDER_REFERENCE_PATTERN" %}
{% include "InvenTree/settings/setting.html" with key="BUILDORDER_REQUIRE_RESPONSIBLE" %}
{% include "InvenTree/settings/setting.html" with key="BUILDORDER_REQUIRE_ACTIVE_PART" %}
{% include "InvenTree/settings/setting.html" with key="BUILDORDER_REQUIRE_LOCKED_PART" %}
{% include "InvenTree/settings/setting.html" with key="BUILDORDER_REQUIRE_VALID_BOM" %}
{% include "InvenTree/settings/setting.html" with key="PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS" %}
</tbody>

View File

@ -1311,7 +1311,7 @@ function loadBomTable(table, options={}) {
return renderLink(
'{% trans "View BOM" %}',
`/part/${row.part}/bom/`
`/part/${row.part}/?display=bom`
);
}
},

View File

@ -25,13 +25,6 @@
printLabels,
*/
const defaultLabelTemplates = {
part: user_settings.DEFAULT_PART_LABEL_TEMPLATE,
location: user_settings.DEFAULT_LOCATION_LABEL_TEMPLATE,
item: user_settings.DEFAULT_ITEM_LABEL_TEMPLATE,
line: user_settings.DEFAULT_LINE_LABEL_TEMPLATE,
}
/*
* Print label(s) for the selected items:

View File

@ -215,9 +215,8 @@ function partFields(options={}) {
// If editing a part, we can set the "active" status
if (options.edit) {
fields.active = {
group: 'attributes'
};
fields.active = {};
fields.locked = {};
}
// Pop 'expiry' field
@ -815,6 +814,10 @@ function makePartIcons(part) {
html += `<span class='badge badge-right rounded-pill bg-warning'>{% trans "Inactive" %}</span> `;
}
if (part.locked) {
html += `<span class='badge badge-right rounded-pill bg-warning'>{% trans "Locked" %}</span>`;
}
return html;
}

View File

@ -716,6 +716,11 @@ function getPartTableFilters() {
title: '{% trans "Active" %}',
description: '{% trans "Show active parts" %}',
},
locked: {
type: 'bool',
title: '{% trans "Locked" %}',
description: '{% trans "Show locked parts" %}',
},
assembly: {
type: 'bool',
title: '{% trans "Assembly" %}',

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);