mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-17 12:35:46 +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:
@ -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);
|
||||
|
||||
|
Reference in New Issue
Block a user