mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
[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:
parent
97b6258797
commit
8309eb628f
16
docs/docs/build/build.md
vendored
16
docs/docs/build/build.md
vendored
@ -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.
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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."""
|
||||
|
@ -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'),
|
||||
|
@ -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."""
|
||||
|
18
src/backend/InvenTree/part/migrations/0125_part_locked.py
Normal file
18
src/backend/InvenTree/part/migrations/0125_part_locked.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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(
|
||||
|
@ -632,6 +632,7 @@ class PartSerializer(
|
||||
'keywords',
|
||||
'last_stocktake',
|
||||
'link',
|
||||
'locked',
|
||||
'minimum_stock',
|
||||
'name',
|
||||
'notes',
|
||||
|
@ -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()
|
||||
|
@ -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."""
|
||||
|
@ -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."""
|
||||
|
@ -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>
|
||||
|
@ -1311,7 +1311,7 @@ function loadBomTable(table, options={}) {
|
||||
|
||||
return renderLink(
|
||||
'{% trans "View BOM" %}',
|
||||
`/part/${row.part}/bom/`
|
||||
`/part/${row.part}/?display=bom`
|
||||
);
|
||||
}
|
||||
},
|
||||
|
@ -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:
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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" %}',
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -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)}
|
||||
/>
|
||||
|
@ -46,6 +46,7 @@ export function usePartFields({
|
||||
purchaseable: {},
|
||||
salable: {},
|
||||
virtual: {},
|
||||
locked: {},
|
||||
active: {}
|
||||
};
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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'
|
||||
]}
|
||||
|
@ -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"
|
||||
|
@ -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 />
|
||||
);
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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`,
|
||||
|
@ -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);
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user