From 7d61203be8117faacecd565dc3de89ee8b7d0ec0 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 24 May 2026 07:41:52 +1000 Subject: [PATCH] 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 --- docs/docs/part/index.md | 2 + docs/docs/settings/global.md | 1 + .../InvenTree/common/setting/system.py | 6 +++ src/backend/InvenTree/part/models.py | 11 +++-- src/backend/InvenTree/part/test_bom_item.py | 39 ++++++++++++++++ src/backend/InvenTree/part/test_param.py | 38 +++++++++++++++ src/backend/InvenTree/part/test_part.py | 22 +++++++++ src/frontend/src/forms/PartForms.tsx | 5 ++ .../pages/Index/Settings/SystemSettings.tsx | 1 + src/frontend/src/pages/part/PartDetail.tsx | 46 ++++++++++++------- src/frontend/src/tables/ColumnRenderers.tsx | 4 +- src/frontend/src/tables/bom/BomTable.tsx | 36 +++++++++------ 12 files changed, 174 insertions(+), 37 deletions(-) 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({ />,