2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-05-28 03:49:20 +00:00

Global part lock (#11995)

* Add new global setting

* Update lock checks

* Bug fix for task comparison

* Update user interface

* Hide locked field if locking not enabled

* Update existing unit tests

* Update docs
This commit is contained in:
Oliver
2026-05-24 07:41:52 +10:00
committed by GitHub
parent 8e7465dd24
commit 7d61203be8
12 changed files with 174 additions and 37 deletions
+2
View File
@@ -96,6 +96,8 @@ Parts can be locked to prevent them from being modified. This is useful for part
- BOM items cannot be created, edited, or deleted when they are part of a locked assembly - BOM items cannot be created, edited, or deleted when they are part of a locked assembly
- Parameters linked to a locked part cannot be created, edited or deleted - Parameters linked to a locked part cannot be created, edited or deleted
The part locking functionality can be enabled or disabled globally via the [Part Locking](../settings/global.md#parts) system setting (`PART_ENABLE_LOCKING`). When disabled, the locked state of a part is ignored and all operations are permitted.
## Active Parts ## 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. 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.
+1
View File
@@ -162,6 +162,7 @@ Configuration of label printing:
{{ globalsetting("PART_ALLOW_DUPLICATE_IPN") }} {{ globalsetting("PART_ALLOW_DUPLICATE_IPN") }}
{{ globalsetting("PART_ALLOW_EDIT_IPN") }} {{ globalsetting("PART_ALLOW_EDIT_IPN") }}
{{ globalsetting("PART_ALLOW_DELETE_FROM_ASSEMBLY") }} {{ globalsetting("PART_ALLOW_DELETE_FROM_ASSEMBLY") }}
{{ globalsetting("PART_ENABLE_LOCKING") }}
{{ globalsetting("PART_ENABLE_REVISION") }} {{ globalsetting("PART_ENABLE_REVISION") }}
{{ globalsetting("PART_REVISION_ASSEMBLY_ONLY") }} {{ globalsetting("PART_REVISION_ASSEMBLY_ONLY") }}
{{ globalsetting("PART_NAME_FORMAT") }} {{ globalsetting("PART_NAME_FORMAT") }}
@@ -402,6 +402,12 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'choices': barcode_plugins, 'choices': barcode_plugins,
'default': 'inventreebarcode', 'default': 'inventreebarcode',
}, },
'PART_ENABLE_LOCKING': {
'name': _('Part Locking'),
'description': _('Enable locking of parts to prevent modification'),
'validator': bool,
'default': True,
},
'PART_ENABLE_REVISION': { 'PART_ENABLE_REVISION': {
'name': _('Part Revisions'), 'name': _('Part Revisions'),
'description': _('Enable revision field for Part'), 'description': _('Enable revision field for Part'),
+6 -5
View File
@@ -570,13 +570,13 @@ class Part(
} }
def check_parameter_delete(self, parameter): def check_parameter_delete(self, parameter):
"""Custom delete check for Paramteter instances associated with this Part.""" """Custom delete check for Parameter instances associated with this Part."""
if self.locked: if self.locked and get_global_setting('PART_ENABLE_LOCKING'):
raise ValidationError(_('Cannot delete parameters of a locked part')) raise ValidationError(_('Cannot delete parameters of a locked part'))
def check_parameter_save(self, parameter): def check_parameter_save(self, parameter):
"""Custom save check for Parameter instances associated with this Part.""" """Custom save check for Parameter instances associated with this Part."""
if self.locked: if self.locked and get_global_setting('PART_ENABLE_LOCKING'):
raise ValidationError(_('Cannot modify parameters of a locked part')) raise ValidationError(_('Cannot modify parameters of a locked part'))
def delete(self, **kwargs): def delete(self, **kwargs):
@@ -587,7 +587,7 @@ class Part(
- The part is still active - The part is still active
- The part is used in a BOM for a different part. - The part is used in a BOM for a different part.
""" """
if self.locked: if self.locked and get_global_setting('PART_ENABLE_LOCKING'):
raise ValidationError(_('Cannot delete this part as it is locked')) raise ValidationError(_('Cannot delete this part as it is locked'))
if self.active: if self.active:
@@ -4066,7 +4066,8 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
Raises: Raises:
ValidationError: If the assembly is locked ValidationError: If the assembly is locked
""" """
# TODO: Perhaps control this with a global setting? if not get_global_setting('PART_ENABLE_LOCKING'):
return
if assembly.locked: if assembly.locked:
raise ValidationError(_('BOM item cannot be modified - assembly is locked')) raise ValidationError(_('BOM item cannot be modified - assembly is locked'))
@@ -8,6 +8,7 @@ from django.test import TestCase
import build.models import build.models
import stock.models import stock.models
from common.settings import set_global_setting
from .models import BomItem, BomItemSubstitute, Part from .models import BomItem, BomItemSubstitute, Part
@@ -391,6 +392,44 @@ class BomItemTest(TestCase):
# Delete the new BOM item # Delete the new BOM item
bom_item.delete() bom_item.delete()
def test_locked_assembly_locking_disabled(self):
"""Test that a locked assembly is not enforced when PART_ENABLE_LOCKING is disabled."""
assembly = Part.objects.create(
name='Assembly3', description='An assembly part', assembly=True
)
sub_part = Part.objects.create(
name='SubPart2', description='A sub-part', component=True
)
bom_item = BomItem.objects.create(part=assembly, sub_part=sub_part, quantity=1)
assembly.locked = True
assembly.save()
# With locking enabled (default), editing is blocked
with self.assertRaises(django_exceptions.ValidationError):
bom_item.quantity = 5
bom_item.save()
# Disable locking globally — all BOM operations should now be allowed
set_global_setting('PART_ENABLE_LOCKING', False)
bom_item.quantity = 5
bom_item.save()
BomItem.objects.create(part=assembly, sub_part=sub_part, quantity=2)
bom_item.delete()
# Re-enable for other tests
set_global_setting('PART_ENABLE_LOCKING', True)
# Confirm locking is enforced again
bom_item2 = BomItem.objects.get(part=assembly, sub_part=sub_part, quantity=2)
with self.assertRaises(django_exceptions.ValidationError):
bom_item2.quantity = 99
bom_item2.save()
def test_bom_validated(self): def test_bom_validated(self):
"""Test for caching of 'bom_validated' property.""" """Test for caching of 'bom_validated' property."""
from part.tasks import validate_bom from part.tasks import validate_bom
+38
View File
@@ -6,6 +6,7 @@ from django.test import TestCase, TransactionTestCase
from django.urls import reverse from django.urls import reverse
from common.models import InvenTreeSetting, Parameter, ParameterTemplate from common.models import InvenTreeSetting, Parameter, ParameterTemplate
from common.settings import set_global_setting
from InvenTree.unit_test import InvenTreeAPITestCase from InvenTree.unit_test import InvenTreeAPITestCase
from .models import Part, PartCategory, PartCategoryParameterTemplate from .models import Part, PartCategory, PartCategoryParameterTemplate
@@ -141,6 +142,43 @@ class TestParams(TestCase):
# And we can delete the parameter # And we can delete the parameter
parameter.delete() parameter.delete()
def test_locked_part_locking_disabled(self):
"""Test that parameter restrictions are lifted when PART_ENABLE_LOCKING is disabled."""
part = Part.objects.create(
name='Test Part Lock Override',
description='Part for testing global locking override',
category=PartCategory.objects.first(),
)
parameter = Parameter.objects.create(
content_object=part, template=ParameterTemplate.objects.first(), data='100'
)
part.locked = True
part.save()
# With locking enabled (default), editing and deletion are blocked
with self.assertRaises(django_exceptions.ValidationError):
parameter.data = '200'
parameter.save()
with self.assertRaises(django_exceptions.ValidationError):
parameter.delete()
# Disable locking globally — parameter operations should now succeed
set_global_setting('PART_ENABLE_LOCKING', False)
parameter.data = '200'
parameter.save()
self.assertEqual(parameter.data, '200')
# Re-enable locking — editing should be blocked again
set_global_setting('PART_ENABLE_LOCKING', True)
with self.assertRaises(django_exceptions.ValidationError):
parameter.data = '300'
parameter.save()
class TestCategoryTemplates(TransactionTestCase): class TestCategoryTemplates(TransactionTestCase):
"""Test class for PartCategoryParameterTemplate model.""" """Test class for PartCategoryParameterTemplate model."""
+22
View File
@@ -353,6 +353,28 @@ class PartTest(TestCase):
part.delete() part.delete()
def test_delete_locking_disabled(self):
"""Test that a locked part can be deleted when PART_ENABLE_LOCKING is disabled."""
part = Part.objects.create(
name='Locked Part Test',
description='Part for testing locking override',
active=False,
)
part.locked = True
part.save()
# With locking enabled (default), deletion of a locked part raises an error
with self.assertRaises(ValidationError):
part.delete()
# Disable locking globally — locked part should now be deletable
set_global_setting('PART_ENABLE_LOCKING', False)
part.delete()
# Re-enable for other tests
set_global_setting('PART_ENABLE_LOCKING', True)
def test_revisions(self): def test_revisions(self):
"""Test the 'revision' and 'revision_of' field.""" """Test the 'revision' and 'revision_of' field."""
template = Part.objects.create( template = Part.objects.create(
+5
View File
@@ -188,6 +188,11 @@ export function usePartFields({
delete fields['default_expiry']; delete fields['default_expiry'];
} }
// Remove "locked" field if locking not enabled
if (!globalSettings.isSet('PART_ENABLE_LOCKING')) {
delete fields['locked'];
}
if (create) { if (create) {
delete fields['starred']; delete fields['starred'];
} }
@@ -212,6 +212,7 @@ export default function SystemSettings() {
'PART_ALLOW_DUPLICATE_IPN', 'PART_ALLOW_DUPLICATE_IPN',
'PART_ALLOW_EDIT_IPN', 'PART_ALLOW_EDIT_IPN',
'PART_ALLOW_DELETE_FROM_ASSEMBLY', 'PART_ALLOW_DELETE_FROM_ASSEMBLY',
'PART_ENABLE_LOCKING',
'PART_ENABLE_REVISION', 'PART_ENABLE_REVISION',
'PART_REVISION_ASSEMBLY_ONLY', 'PART_REVISION_ASSEMBLY_ONLY',
'PART_SHOW_RELATED', 'PART_SHOW_RELATED',
+29 -17
View File
@@ -202,6 +202,11 @@ export default function PartDetail() {
refetchOnMount: true refetchOnMount: true
}); });
const lockingEnabled = useMemo(
() => globalSettings.isSet('PART_ENABLE_LOCKING'),
[globalSettings]
);
const revisionsEnabled = useMemo( const revisionsEnabled = useMemo(
() => globalSettings.isSet('PART_ENABLE_REVISION'), () => globalSettings.isSet('PART_ENABLE_REVISION'),
[globalSettings] [globalSettings]
@@ -808,7 +813,12 @@ export default function PartDetail() {
icon: <IconTestPipe />, icon: <IconTestPipe />,
hidden: !part.testable, hidden: !part.testable,
content: part?.pk ? ( content: part?.pk ? (
<PartTestTemplateTable partId={part?.pk} partLocked={part.locked} /> <PartTestTemplateTable
partId={part?.pk}
partLocked={
globalSettings.isSet('PART_ENABLE_LOCKING') && part?.locked
}
/>
) : ( ) : (
<Skeleton /> <Skeleton />
) )
@@ -836,7 +846,7 @@ export default function PartDetail() {
icon: <IconListDetails />, icon: <IconListDetails />,
content: ( content: (
<> <>
{part.locked && ( {lockingEnabled && part.locked && (
<Alert <Alert
title={t`Part is Locked`} title={t`Part is Locked`}
color='orange' color='orange'
@@ -849,7 +859,7 @@ export default function PartDetail() {
<ParameterTable <ParameterTable
modelType={ModelType.part} modelType={ModelType.part}
modelId={part?.pk} modelId={part?.pk}
allowEdit={part?.locked != true} allowEdit={!lockingEnabled || part?.locked != true}
/> />
</> </>
) )
@@ -1149,20 +1159,22 @@ export default function PartDetail() {
<PageDetail <PageDetail
title={`${t`Part`}: ${part.full_name}`} title={`${t`Part`}: ${part.full_name}`}
icon={ icon={
<ActionIcon lockingEnabled ? (
aria-label='part-lock-icon' <ActionIcon
variant='transparent' aria-label='part-lock-icon'
disabled={!user.hasChangeRole(UserRoles.part)} variant='transparent'
onClick={() => { disabled={!user.hasChangeRole(UserRoles.part)}
api onClick={() => {
.patch(apiUrl(ApiEndpoints.part_list, part.pk), { api
locked: !part.locked .patch(apiUrl(ApiEndpoints.part_list, part.pk), {
}) locked: !part.locked
.then(refreshInstance); })
}} .then(refreshInstance);
> }}
{part?.locked ? <IconLock /> : <IconLockOpen />} >
</ActionIcon> {part?.locked ? <IconLock /> : <IconLockOpen />}
</ActionIcon>
) : undefined
} }
subtitle={part.description} subtitle={part.description}
imageUrl={part.image} imageUrl={part.image}
+3 -1
View File
@@ -52,6 +52,8 @@ export function RenderPartColumn({
part: any; part: any;
full_name?: boolean; full_name?: boolean;
}) { }) {
const globalSettings = useGlobalSettingsState.getState();
if (!part) { if (!part) {
return <Skeleton />; return <Skeleton />;
} }
@@ -69,7 +71,7 @@ export function RenderPartColumn({
<IconExclamationCircle color='red' size={16} /> <IconExclamationCircle color='red' size={16} />
</Tooltip> </Tooltip>
)} )}
{part?.locked && ( {globalSettings.isSet('PART_ENABLE_LOCKING') && part?.locked && (
<Tooltip label={t`Part is Locked`}> <Tooltip label={t`Part is Locked`}>
<IconLock size={16} /> <IconLock size={16} />
</Tooltip> </Tooltip>
+22 -14
View File
@@ -40,7 +40,10 @@ import {
useEditApiFormModal useEditApiFormModal
} from '../../hooks/UseForm'; } from '../../hooks/UseForm';
import { useImporterState } from '../../states/ImporterState'; import { useImporterState } from '../../states/ImporterState';
import { useUserSettingsState } from '../../states/SettingsStates'; import {
useGlobalSettingsState,
useUserSettingsState
} from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import { import {
BooleanColumn, BooleanColumn,
@@ -91,6 +94,13 @@ export function BomTable({
const [isEditing, setIsEditing] = useState<boolean>(false); const [isEditing, setIsEditing] = useState<boolean>(false);
const globalSettings = useGlobalSettingsState();
const isLocked = useMemo(
() => globalSettings.isSet('PART_ENABLE_LOCKING') && (partLocked ?? false),
[globalSettings, partLocked]
);
const userSettings = useUserSettingsState(); const userSettings = useUserSettingsState();
const tableColumns: TableColumn[] = useMemo(() => { const tableColumns: TableColumn[] = useMemo(() => {
@@ -605,16 +615,14 @@ export function BomTable({
title: t`Validate BOM Line`, title: t`Validate BOM Line`,
color: 'green', color: 'green',
hidden: hidden:
partLocked || isLocked || record.validated || !user.hasChangeRole(UserRoles.bom),
record.validated ||
!user.hasChangeRole(UserRoles.bom),
icon: <IconCircleCheck />, icon: <IconCircleCheck />,
onClick: () => { onClick: () => {
validateBomItem(record); validateBomItem(record);
} }
}, },
RowEditAction({ RowEditAction({
hidden: partLocked || !user.hasChangeRole(UserRoles.bom), hidden: isLocked || !user.hasChangeRole(UserRoles.bom),
onClick: () => { onClick: () => {
setSelectedBomItem(record); setSelectedBomItem(record);
editBomItem.open(); editBomItem.open();
@@ -623,7 +631,7 @@ export function BomTable({
{ {
title: t`Edit Substitutes`, title: t`Edit Substitutes`,
color: 'blue', color: 'blue',
hidden: partLocked || !user.hasAddRole(UserRoles.bom), hidden: isLocked || !user.hasAddRole(UserRoles.bom),
icon: <IconSwitch3 />, icon: <IconSwitch3 />,
onClick: () => { onClick: () => {
setSelectedBomItem(record); setSelectedBomItem(record);
@@ -631,7 +639,7 @@ export function BomTable({
} }
}, },
RowDeleteAction({ RowDeleteAction({
hidden: partLocked || !user.hasDeleteRole(UserRoles.bom), hidden: isLocked || !user.hasDeleteRole(UserRoles.bom),
onClick: () => { onClick: () => {
setSelectedBomItem(record); setSelectedBomItem(record);
deleteBomItem.open(); deleteBomItem.open();
@@ -639,7 +647,7 @@ export function BomTable({
}) })
]; ];
}, },
[isEditing, partId, partLocked, user] [isEditing, partId, isLocked, user]
); );
const tableActions = useMemo(() => { const tableActions = useMemo(() => {
@@ -649,7 +657,7 @@ export function BomTable({
tooltip={t`Add BOM Items`} tooltip={t`Add BOM Items`}
position='bottom-start' position='bottom-start'
icon={<IconPlus />} icon={<IconPlus />}
hidden={!isEditing || partLocked || !user.hasAddRole(UserRoles.bom)} hidden={!isEditing || isLocked || !user.hasAddRole(UserRoles.bom)}
actions={[ actions={[
{ {
name: t`Add BOM Item`, name: t`Add BOM Item`,
@@ -667,7 +675,7 @@ export function BomTable({
/>, />,
<ActionButton <ActionButton
key='edit-bom' key='edit-bom'
hidden={partLocked || !user.hasChangeRole(UserRoles.bom) || isEditing} hidden={isLocked || !user.hasChangeRole(UserRoles.bom) || isEditing}
tooltip={t`Edit BOM`} tooltip={t`Edit BOM`}
icon={<IconEdit />} icon={<IconEdit />}
onClick={() => { onClick={() => {
@@ -686,7 +694,7 @@ export function BomTable({
}} }}
/> />
]; ];
}, [isEditing, partLocked, user]); }, [isEditing, isLocked, user]);
// Row expansion (for displaying subassemblies) // Row expansion (for displaying subassemblies)
const rowExpansion = subassemblyRowExpansion({ table: table }); const rowExpansion = subassemblyRowExpansion({ table: table });
@@ -699,7 +707,7 @@ export function BomTable({
{deleteBomItem.modal} {deleteBomItem.modal}
{editSubstitutes.modal} {editSubstitutes.modal}
<Stack gap='xs'> <Stack gap='xs'>
{partLocked && ( {isLocked && (
<Alert <Alert
title={t`Part is Locked`} title={t`Part is Locked`}
color='orange' color='orange'
@@ -730,9 +738,9 @@ export function BomTable({
modelField: 'sub_part', modelField: 'sub_part',
onCellClick: () => {}, onCellClick: () => {},
rowActions: isEditing ? rowActions : undefined, rowActions: isEditing ? rowActions : undefined,
enableSelection: isEditing && !partLocked, enableSelection: isEditing && !isLocked,
enableBulkDelete: enableBulkDelete:
isEditing && !partLocked && user.hasDeleteRole(UserRoles.bom), isEditing && !isLocked && user.hasDeleteRole(UserRoles.bom),
enableDownload: true, enableDownload: true,
rowExpansion: isEditing ? undefined : rowExpansion rowExpansion: isEditing ? undefined : rowExpansion
}} }}