From 8309eb628fd5e8ba0f563627ded4edff05959936 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 7 Jul 2024 11:35:30 +1000 Subject: [PATCH] [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 * 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 --- docs/docs/build/build.md | 16 ++++ docs/docs/part/part.md | 10 ++- .../InvenTree/InvenTree/api_version.py | 6 +- src/backend/InvenTree/InvenTree/models.py | 2 +- src/backend/InvenTree/build/models.py | 9 ++- src/backend/InvenTree/build/tests.py | 17 ++++- src/backend/InvenTree/common/models.py | 36 ++------- src/backend/InvenTree/part/api.py | 10 +++ .../part/migrations/0125_part_locked.py | 18 +++++ src/backend/InvenTree/part/models.py | 66 ++++++++++++++++ src/backend/InvenTree/part/serializers.py | 1 + src/backend/InvenTree/part/test_bom_item.py | 47 ++++++++++++ src/backend/InvenTree/part/test_param.py | 37 +++++++++ src/backend/InvenTree/part/test_part.py | 18 +++++ .../templates/InvenTree/settings/build.html | 1 + .../InvenTree/templates/js/translated/bom.js | 2 +- .../templates/js/translated/label.js | 7 -- .../InvenTree/templates/js/translated/part.js | 9 ++- .../templates/js/translated/table_filters.js | 5 ++ .../src/components/details/PartIcons.tsx | 14 ++-- .../components/forms/fields/ApiFormField.tsx | 7 +- src/frontend/src/forms/PartForms.tsx | 1 + src/frontend/src/functions/icons.tsx | 3 + .../pages/Index/Settings/SystemSettings.tsx | 1 + src/frontend/src/pages/part/PartDetail.tsx | 22 +++++- src/frontend/src/tables/ColumnRenderers.tsx | 25 +++++-- src/frontend/src/tables/bom/BomTable.tsx | 74 +++++++++++------- .../src/tables/part/PartParameterTable.tsx | 75 ++++++++++++------- src/frontend/src/tables/part/PartTable.tsx | 7 ++ src/frontend/tests/pages/pui_part.spec.ts | 21 ++++++ 30 files changed, 448 insertions(+), 119 deletions(-) create mode 100644 src/backend/InvenTree/part/migrations/0125_part_locked.py diff --git a/docs/docs/build/build.md b/docs/docs/build/build.md index cf571be503..22f4084761 100644 --- a/docs/docs/build/build.md +++ b/docs/docs/build/build.md @@ -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. diff --git a/docs/docs/part/part.md b/docs/docs/part/part.md index acb90571df..5b39e1c77f 100644 --- a/docs/docs/part/part.md +++ b/docs/docs/part/part.md @@ -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. diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index a4b8b47cfe..87124db26a 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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) diff --git a/src/backend/InvenTree/InvenTree/models.py b/src/backend/InvenTree/InvenTree/models.py index 3e0e99f005..33cd1e9097 100644 --- a/src/backend/InvenTree/InvenTree/models.py +++ b/src/backend/InvenTree/InvenTree/models.py @@ -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 diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index 5e7e9f48b2..326e484137 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -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 diff --git a/src/backend/InvenTree/build/tests.py b/src/backend/InvenTree/build/tests.py index 107e165a6c..22d38cbeb8 100644 --- a/src/backend/InvenTree/build/tests.py +++ b/src/backend/InvenTree/build/tests.py @@ -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.""" diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index c8c41a61f7..9d41d06f39 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -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'), diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index f2636f455a..74522b0268 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -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.""" diff --git a/src/backend/InvenTree/part/migrations/0125_part_locked.py b/src/backend/InvenTree/part/migrations/0125_part_locked.py new file mode 100644 index 0000000000..a5ac79876c --- /dev/null +++ b/src/backend/InvenTree/part/migrations/0125_part_locked.py @@ -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'), + ), + ] diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index 34c4314065..b7cb80c101 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -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( diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index fefa5596e2..f299c74cbb 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -632,6 +632,7 @@ class PartSerializer( 'keywords', 'last_stocktake', 'link', + 'locked', 'minimum_stock', 'name', 'notes', diff --git a/src/backend/InvenTree/part/test_bom_item.py b/src/backend/InvenTree/part/test_bom_item.py index 86d4578f4a..f978f90638 100644 --- a/src/backend/InvenTree/part/test_bom_item.py +++ b/src/backend/InvenTree/part/test_bom_item.py @@ -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() diff --git a/src/backend/InvenTree/part/test_param.py b/src/backend/InvenTree/part/test_param.py index 314fe11eb5..60f6baa8c2 100644 --- a/src/backend/InvenTree/part/test_param.py +++ b/src/backend/InvenTree/part/test_param.py @@ -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.""" diff --git a/src/backend/InvenTree/part/test_part.py b/src/backend/InvenTree/part/test_part.py index 58a88c3055..6e85c11cc0 100644 --- a/src/backend/InvenTree/part/test_part.py +++ b/src/backend/InvenTree/part/test_part.py @@ -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.""" diff --git a/src/backend/InvenTree/templates/InvenTree/settings/build.html b/src/backend/InvenTree/templates/InvenTree/settings/build.html index e3029a3278..9eb1ce47bb 100644 --- a/src/backend/InvenTree/templates/InvenTree/settings/build.html +++ b/src/backend/InvenTree/templates/InvenTree/settings/build.html @@ -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" %} diff --git a/src/backend/InvenTree/templates/js/translated/bom.js b/src/backend/InvenTree/templates/js/translated/bom.js index 11d03c095e..f54f96acc1 100644 --- a/src/backend/InvenTree/templates/js/translated/bom.js +++ b/src/backend/InvenTree/templates/js/translated/bom.js @@ -1311,7 +1311,7 @@ function loadBomTable(table, options={}) { return renderLink( '{% trans "View BOM" %}', - `/part/${row.part}/bom/` + `/part/${row.part}/?display=bom` ); } }, diff --git a/src/backend/InvenTree/templates/js/translated/label.js b/src/backend/InvenTree/templates/js/translated/label.js index fff34aecf6..2a3e890361 100644 --- a/src/backend/InvenTree/templates/js/translated/label.js +++ b/src/backend/InvenTree/templates/js/translated/label.js @@ -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: diff --git a/src/backend/InvenTree/templates/js/translated/part.js b/src/backend/InvenTree/templates/js/translated/part.js index f28281cfd7..e4b4107d5b 100644 --- a/src/backend/InvenTree/templates/js/translated/part.js +++ b/src/backend/InvenTree/templates/js/translated/part.js @@ -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 += `{% trans "Inactive" %} `; } + if (part.locked) { + html += `{% trans "Locked" %}`; + } + return html; } diff --git a/src/backend/InvenTree/templates/js/translated/table_filters.js b/src/backend/InvenTree/templates/js/translated/table_filters.js index 0c10b06f33..c32b69f8b6 100644 --- a/src/backend/InvenTree/templates/js/translated/table_filters.js +++ b/src/backend/InvenTree/templates/js/translated/table_filters.js @@ -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" %}', diff --git a/src/frontend/src/components/details/PartIcons.tsx b/src/frontend/src/components/details/PartIcons.tsx index 5a39a85be1..78086bc628 100644 --- a/src/frontend/src/components/details/PartIcons.tsx +++ b/src/frontend/src/components/details/PartIcons.tsx @@ -24,15 +24,17 @@ export function PartIcons({ part }: { part: any }) { return (
+ {part.locked && ( + + + Locked + + + )} {!part.active && ( -
- {' '} - Inactive -
+ Inactive
)} diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx index 6c781ac61f..cec43ec526 100644 --- a/src/frontend/src/components/forms/fields/ApiFormField.tsx +++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx @@ -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 ( onChange(event.currentTarget.checked)} /> diff --git a/src/frontend/src/forms/PartForms.tsx b/src/frontend/src/forms/PartForms.tsx index e7f42be314..8bd294d303 100644 --- a/src/frontend/src/forms/PartForms.tsx +++ b/src/frontend/src/forms/PartForms.tsx @@ -46,6 +46,7 @@ export function usePartFields({ purchaseable: {}, salable: {}, virtual: {}, + locked: {}, active: {} }; diff --git a/src/frontend/src/functions/icons.tsx b/src/frontend/src/functions/icons.tsx index 75002f4701..bafde65c76 100644 --- a/src/frontend/src/functions/icons.tsx +++ b/src/frontend/src/functions/icons.tsx @@ -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, diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx index 0492d6e7ad..14f96f2495 100644 --- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx +++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx @@ -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' ]} diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 838d304b11..09e70f03f6 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -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: , - content: + content: ( + + ) }, { name: 'stock', @@ -520,7 +530,9 @@ export default function PartDetail() { label: t`Bill of Materials`, icon: , hidden: !part.assembly, - content: + content: ( + + ) }, { name: 'builds', @@ -681,6 +693,12 @@ export default function PartDetail() { visible={part.building > 0} key="in_production" />, + , + + + + {part?.active == false && ( + + + + )} + {part?.locked && ( + + + + )} + + ) : ( ); diff --git a/src/frontend/src/tables/bom/BomTable.tsx b/src/frontend/src/tables/bom/BomTable.tsx index 4109b0f89a..2a22430219 100644 --- a/src/frontend/src/tables/bom/BomTable.tsx +++ b/src/frontend/src/tables/bom/BomTable.tsx @@ -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: , 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: }, 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 [