diff --git a/docs/docs/part/index.md b/docs/docs/part/index.md
index dc250131bf..ec7f2fa282 100644
--- a/docs/docs/part/index.md
+++ b/docs/docs/part/index.md
@@ -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
- 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
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.
diff --git a/docs/docs/settings/global.md b/docs/docs/settings/global.md
index 4ae4d02f40..752ef1b910 100644
--- a/docs/docs/settings/global.md
+++ b/docs/docs/settings/global.md
@@ -162,6 +162,7 @@ Configuration of label printing:
{{ globalsetting("PART_ALLOW_DUPLICATE_IPN") }}
{{ globalsetting("PART_ALLOW_EDIT_IPN") }}
{{ globalsetting("PART_ALLOW_DELETE_FROM_ASSEMBLY") }}
+{{ globalsetting("PART_ENABLE_LOCKING") }}
{{ globalsetting("PART_ENABLE_REVISION") }}
{{ globalsetting("PART_REVISION_ASSEMBLY_ONLY") }}
{{ globalsetting("PART_NAME_FORMAT") }}
diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py
index 0f1283be06..c646aa843d 100644
--- a/src/backend/InvenTree/common/setting/system.py
+++ b/src/backend/InvenTree/common/setting/system.py
@@ -402,6 +402,12 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'choices': barcode_plugins,
'default': 'inventreebarcode',
},
+ 'PART_ENABLE_LOCKING': {
+ 'name': _('Part Locking'),
+ 'description': _('Enable locking of parts to prevent modification'),
+ 'validator': bool,
+ 'default': True,
+ },
'PART_ENABLE_REVISION': {
'name': _('Part Revisions'),
'description': _('Enable revision field for Part'),
diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py
index 0548ffbef3..48b0bb0399 100644
--- a/src/backend/InvenTree/part/models.py
+++ b/src/backend/InvenTree/part/models.py
@@ -570,13 +570,13 @@ class Part(
}
def check_parameter_delete(self, parameter):
- """Custom delete check for Paramteter instances associated with this Part."""
- if self.locked:
+ """Custom delete check for Parameter instances associated with this Part."""
+ if self.locked and get_global_setting('PART_ENABLE_LOCKING'):
raise ValidationError(_('Cannot delete parameters of a locked part'))
def check_parameter_save(self, parameter):
"""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'))
def delete(self, **kwargs):
@@ -587,7 +587,7 @@ class Part(
- The part is still active
- 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'))
if self.active:
@@ -4066,7 +4066,8 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
Raises:
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:
raise ValidationError(_('BOM item cannot be modified - assembly is locked'))
diff --git a/src/backend/InvenTree/part/test_bom_item.py b/src/backend/InvenTree/part/test_bom_item.py
index 1e9a686a77..13bebc5e3c 100644
--- a/src/backend/InvenTree/part/test_bom_item.py
+++ b/src/backend/InvenTree/part/test_bom_item.py
@@ -8,6 +8,7 @@ from django.test import TestCase
import build.models
import stock.models
+from common.settings import set_global_setting
from .models import BomItem, BomItemSubstitute, Part
@@ -391,6 +392,44 @@ class BomItemTest(TestCase):
# Delete the new BOM item
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):
"""Test for caching of 'bom_validated' property."""
from part.tasks import validate_bom
diff --git a/src/backend/InvenTree/part/test_param.py b/src/backend/InvenTree/part/test_param.py
index 76b3fb241b..41e6c3c4b0 100644
--- a/src/backend/InvenTree/part/test_param.py
+++ b/src/backend/InvenTree/part/test_param.py
@@ -6,6 +6,7 @@ from django.test import TestCase, TransactionTestCase
from django.urls import reverse
from common.models import InvenTreeSetting, Parameter, ParameterTemplate
+from common.settings import set_global_setting
from InvenTree.unit_test import InvenTreeAPITestCase
from .models import Part, PartCategory, PartCategoryParameterTemplate
@@ -141,6 +142,43 @@ class TestParams(TestCase):
# And we can delete the parameter
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):
"""Test class for PartCategoryParameterTemplate model."""
diff --git a/src/backend/InvenTree/part/test_part.py b/src/backend/InvenTree/part/test_part.py
index 81551bfabf..05e58d225e 100644
--- a/src/backend/InvenTree/part/test_part.py
+++ b/src/backend/InvenTree/part/test_part.py
@@ -353,6 +353,28 @@ class PartTest(TestCase):
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):
"""Test the 'revision' and 'revision_of' field."""
template = Part.objects.create(
diff --git a/src/frontend/src/forms/PartForms.tsx b/src/frontend/src/forms/PartForms.tsx
index 32f6c85391..81305626a1 100644
--- a/src/frontend/src/forms/PartForms.tsx
+++ b/src/frontend/src/forms/PartForms.tsx
@@ -188,6 +188,11 @@ export function usePartFields({
delete fields['default_expiry'];
}
+ // Remove "locked" field if locking not enabled
+ if (!globalSettings.isSet('PART_ENABLE_LOCKING')) {
+ delete fields['locked'];
+ }
+
if (create) {
delete fields['starred'];
}
diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx
index 03d369be91..0078520bd9 100644
--- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx
+++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx
@@ -212,6 +212,7 @@ export default function SystemSettings() {
'PART_ALLOW_DUPLICATE_IPN',
'PART_ALLOW_EDIT_IPN',
'PART_ALLOW_DELETE_FROM_ASSEMBLY',
+ 'PART_ENABLE_LOCKING',
'PART_ENABLE_REVISION',
'PART_REVISION_ASSEMBLY_ONLY',
'PART_SHOW_RELATED',
diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx
index a42157c7d1..79c6765ac3 100644
--- a/src/frontend/src/pages/part/PartDetail.tsx
+++ b/src/frontend/src/pages/part/PartDetail.tsx
@@ -202,6 +202,11 @@ export default function PartDetail() {
refetchOnMount: true
});
+ const lockingEnabled = useMemo(
+ () => globalSettings.isSet('PART_ENABLE_LOCKING'),
+ [globalSettings]
+ );
+
const revisionsEnabled = useMemo(
() => globalSettings.isSet('PART_ENABLE_REVISION'),
[globalSettings]
@@ -808,7 +813,12 @@ export default function PartDetail() {
icon: ,
hidden: !part.testable,
content: part?.pk ? (
-
+
) : (
)
@@ -836,7 +846,7 @@ export default function PartDetail() {
icon: ,
content: (
<>
- {part.locked && (
+ {lockingEnabled && part.locked && (
>
)
@@ -1149,20 +1159,22 @@ export default function PartDetail() {
{
- api
- .patch(apiUrl(ApiEndpoints.part_list, part.pk), {
- locked: !part.locked
- })
- .then(refreshInstance);
- }}
- >
- {part?.locked ? : }
-
+ lockingEnabled ? (
+ {
+ api
+ .patch(apiUrl(ApiEndpoints.part_list, part.pk), {
+ locked: !part.locked
+ })
+ .then(refreshInstance);
+ }}
+ >
+ {part?.locked ? : }
+
+ ) : undefined
}
subtitle={part.description}
imageUrl={part.image}
diff --git a/src/frontend/src/tables/ColumnRenderers.tsx b/src/frontend/src/tables/ColumnRenderers.tsx
index 1c0bed0ede..f3c61e8990 100644
--- a/src/frontend/src/tables/ColumnRenderers.tsx
+++ b/src/frontend/src/tables/ColumnRenderers.tsx
@@ -52,6 +52,8 @@ export function RenderPartColumn({
part: any;
full_name?: boolean;
}) {
+ const globalSettings = useGlobalSettingsState.getState();
+
if (!part) {
return ;
}
@@ -69,7 +71,7 @@ export function RenderPartColumn({
)}
- {part?.locked && (
+ {globalSettings.isSet('PART_ENABLE_LOCKING') && part?.locked && (
diff --git a/src/frontend/src/tables/bom/BomTable.tsx b/src/frontend/src/tables/bom/BomTable.tsx
index 89cc217bf3..29e4b2cad4 100644
--- a/src/frontend/src/tables/bom/BomTable.tsx
+++ b/src/frontend/src/tables/bom/BomTable.tsx
@@ -40,7 +40,10 @@ import {
useEditApiFormModal
} from '../../hooks/UseForm';
import { useImporterState } from '../../states/ImporterState';
-import { useUserSettingsState } from '../../states/SettingsStates';
+import {
+ useGlobalSettingsState,
+ useUserSettingsState
+} from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState';
import {
BooleanColumn,
@@ -91,6 +94,13 @@ export function BomTable({
const [isEditing, setIsEditing] = useState(false);
+ const globalSettings = useGlobalSettingsState();
+
+ const isLocked = useMemo(
+ () => globalSettings.isSet('PART_ENABLE_LOCKING') && (partLocked ?? false),
+ [globalSettings, partLocked]
+ );
+
const userSettings = useUserSettingsState();
const tableColumns: TableColumn[] = useMemo(() => {
@@ -605,16 +615,14 @@ export function BomTable({
title: t`Validate BOM Line`,
color: 'green',
hidden:
- partLocked ||
- record.validated ||
- !user.hasChangeRole(UserRoles.bom),
+ isLocked || record.validated || !user.hasChangeRole(UserRoles.bom),
icon: ,
onClick: () => {
validateBomItem(record);
}
},
RowEditAction({
- hidden: partLocked || !user.hasChangeRole(UserRoles.bom),
+ hidden: isLocked || !user.hasChangeRole(UserRoles.bom),
onClick: () => {
setSelectedBomItem(record);
editBomItem.open();
@@ -623,7 +631,7 @@ export function BomTable({
{
title: t`Edit Substitutes`,
color: 'blue',
- hidden: partLocked || !user.hasAddRole(UserRoles.bom),
+ hidden: isLocked || !user.hasAddRole(UserRoles.bom),
icon: ,
onClick: () => {
setSelectedBomItem(record);
@@ -631,7 +639,7 @@ export function BomTable({
}
},
RowDeleteAction({
- hidden: partLocked || !user.hasDeleteRole(UserRoles.bom),
+ hidden: isLocked || !user.hasDeleteRole(UserRoles.bom),
onClick: () => {
setSelectedBomItem(record);
deleteBomItem.open();
@@ -639,7 +647,7 @@ export function BomTable({
})
];
},
- [isEditing, partId, partLocked, user]
+ [isEditing, partId, isLocked, user]
);
const tableActions = useMemo(() => {
@@ -649,7 +657,7 @@ export function BomTable({
tooltip={t`Add BOM Items`}
position='bottom-start'
icon={}
- hidden={!isEditing || partLocked || !user.hasAddRole(UserRoles.bom)}
+ hidden={!isEditing || isLocked || !user.hasAddRole(UserRoles.bom)}
actions={[
{
name: t`Add BOM Item`,
@@ -667,7 +675,7 @@ export function BomTable({
/>,
}
onClick={() => {
@@ -686,7 +694,7 @@ export function BomTable({
}}
/>
];
- }, [isEditing, partLocked, user]);
+ }, [isEditing, isLocked, user]);
// Row expansion (for displaying subassemblies)
const rowExpansion = subassemblyRowExpansion({ table: table });
@@ -699,7 +707,7 @@ export function BomTable({
{deleteBomItem.modal}
{editSubstitutes.modal}
- {partLocked && (
+ {isLocked && (
{},
rowActions: isEditing ? rowActions : undefined,
- enableSelection: isEditing && !partLocked,
+ enableSelection: isEditing && !isLocked,
enableBulkDelete:
- isEditing && !partLocked && user.hasDeleteRole(UserRoles.bom),
+ isEditing && !isLocked && user.hasDeleteRole(UserRoles.bom),
enableDownload: true,
rowExpansion: isEditing ? undefined : rowExpansion
}}