mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 11:36:44 +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
|
- Builds can be filtered by overdue status in the build list
|
||||||
- Overdue builds will be displayed on the home page
|
- 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.
|
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.
|
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 information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
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
|
v212 - 2024-07-06 : https://github.com/inventree/InvenTree/pull/7562
|
||||||
- Makes API generation more robust (no functional changes)
|
- Makes API generation more robust (no functional changes)
|
||||||
|
|
||||||
|
@ -216,7 +216,7 @@ class MetadataMixin(models.Model):
|
|||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
class DataImportMixin(object):
|
class DataImportMixin:
|
||||||
"""Model mixin class which provides support for 'data import' functionality.
|
"""Model mixin class which provides support for 'data import' functionality.
|
||||||
|
|
||||||
Models which implement this mixin should provide information on the fields available for import
|
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
|
# Check that the part is active
|
||||||
if not self.part.active:
|
if not self.part.active:
|
||||||
raise ValidationError({
|
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
|
# On first save (i.e. creation), run some extra checks
|
||||||
|
@ -99,6 +99,10 @@ class BuildTestSimple(InvenTreeTestCase):
|
|||||||
# Find an assembly part
|
# Find an assembly part
|
||||||
assembly = Part.objects.filter(assembly=True).first()
|
assembly = Part.objects.filter(assembly=True).first()
|
||||||
|
|
||||||
|
assembly.active = True
|
||||||
|
assembly.locked = False
|
||||||
|
assembly.save()
|
||||||
|
|
||||||
self.assertEqual(assembly.get_bom_items().count(), 0)
|
self.assertEqual(assembly.get_bom_items().count(), 0)
|
||||||
|
|
||||||
# Let's create some BOM items for this assembly
|
# 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
|
# Create a build for an assembly with an *invalid* BOM
|
||||||
set_global_setting('BUILDORDER_REQUIRE_VALID_BOM', False)
|
set_global_setting('BUILDORDER_REQUIRE_VALID_BOM', False)
|
||||||
set_global_setting('BUILDORDER_REQUIRE_ACTIVE_PART', True)
|
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 = Build.objects.create(part=assembly, quantity=10, reference='BO-9990')
|
||||||
bo.save()
|
bo.save()
|
||||||
@ -147,8 +152,18 @@ class BuildTestSimple(InvenTreeTestCase):
|
|||||||
set_global_setting('BUILDORDER_REQUIRE_ACTIVE_PART', False)
|
set_global_setting('BUILDORDER_REQUIRE_ACTIVE_PART', False)
|
||||||
Build.objects.create(part=assembly, quantity=10, reference='BO-9994')
|
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
|
# 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):
|
class TestBuildViews(InvenTreeTestCase):
|
||||||
"""Tests for Build app views."""
|
"""Tests for Build app views."""
|
||||||
|
@ -1797,6 +1797,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
'default': False,
|
'default': False,
|
||||||
'validator': bool,
|
'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': {
|
'BUILDORDER_REQUIRE_VALID_BOM': {
|
||||||
'name': _('Require Valid BOM'),
|
'name': _('Require Valid BOM'),
|
||||||
'description': _(
|
'description': _(
|
||||||
@ -2485,36 +2491,6 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
|||||||
'validator': [int, MinValueValidator(0)],
|
'validator': [int, MinValueValidator(0)],
|
||||||
'default': 100,
|
'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': {
|
'NOTIFICATION_ERROR_REPORT': {
|
||||||
'name': _('Receive error reports'),
|
'name': _('Receive error reports'),
|
||||||
'description': _('Receive notifications for system errors'),
|
'description': _('Receive notifications for system errors'),
|
||||||
|
@ -1144,6 +1144,8 @@ class PartFilter(rest_filters.FilterSet):
|
|||||||
|
|
||||||
active = rest_filters.BooleanFilter()
|
active = rest_filters.BooleanFilter()
|
||||||
|
|
||||||
|
locked = rest_filters.BooleanFilter()
|
||||||
|
|
||||||
virtual = rest_filters.BooleanFilter()
|
virtual = rest_filters.BooleanFilter()
|
||||||
|
|
||||||
tags_name = rest_filters.CharFilter(field_name='tags__name', lookup_expr='iexact')
|
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',
|
'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):
|
class BomDetail(BomMixin, RetrieveUpdateDestroyAPI):
|
||||||
"""API endpoint for detail view of a single BomItem object."""
|
"""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?
|
purchaseable: Can this part be purchased from suppliers?
|
||||||
trackable: Trackable parts can have unique serial numbers assigned, etc, etc
|
trackable: Trackable parts can have unique serial numbers assigned, etc, etc
|
||||||
active: Is this part active? Parts are deactivated instead of being deleted
|
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
|
virtual: Is this part "virtual"? e.g. a software product or similar
|
||||||
notes: Additional notes field for this part
|
notes: Additional notes field for this part
|
||||||
creation_date: Date that this part was added to the database
|
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 still active
|
||||||
- The part is used in a BOM for a different part.
|
- 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:
|
if self.active:
|
||||||
raise ValidationError(_('Cannot delete this part as it is still 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?')
|
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(
|
virtual = models.BooleanField(
|
||||||
default=part_settings.part_virtual_default,
|
default=part_settings.part_virtual_default,
|
||||||
verbose_name=_('Virtual'),
|
verbose_name=_('Virtual'),
|
||||||
@ -3723,11 +3733,29 @@ class PartParameter(InvenTree.models.InvenTreeMetadataModel):
|
|||||||
"""String representation of a PartParameter (used in the admin interface)."""
|
"""String representation of a PartParameter (used in the admin interface)."""
|
||||||
return f'{self.part.full_name} : {self.template.name} = {self.data} ({self.template.units})'
|
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):
|
def save(self, *args, **kwargs):
|
||||||
"""Custom save method for the PartParameter model."""
|
"""Custom save method for the PartParameter model."""
|
||||||
# Validate the PartParameter before saving
|
# Validate the PartParameter before saving
|
||||||
self.calculate_numeric_value()
|
self.calculate_numeric_value()
|
||||||
|
|
||||||
|
# Check if the part is locked
|
||||||
|
self.check_part_lock()
|
||||||
|
|
||||||
# Convert 'boolean' values to 'True' / 'False'
|
# Convert 'boolean' values to 'True' / 'False'
|
||||||
if self.template.checkbox:
|
if self.template.checkbox:
|
||||||
self.data = str2bool(self.data)
|
self.data = str2bool(self.data)
|
||||||
@ -4037,15 +4065,53 @@ class BomItem(
|
|||||||
"""
|
"""
|
||||||
return Q(part__in=self.get_valid_parts_for_allocation())
|
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):
|
def save(self, *args, **kwargs):
|
||||||
"""Enforce 'clean' operation when saving a BomItem instance."""
|
"""Enforce 'clean' operation when saving a BomItem instance."""
|
||||||
self.clean()
|
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
|
# Update the 'validated' field based on checksum calculation
|
||||||
self.validated = self.is_line_valid
|
self.validated = self.is_line_valid
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
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
|
# A link to the parent part
|
||||||
# Each part will get a reverse lookup field 'bom_items'
|
# Each part will get a reverse lookup field 'bom_items'
|
||||||
part = models.ForeignKey(
|
part = models.ForeignKey(
|
||||||
|
@ -632,6 +632,7 @@ class PartSerializer(
|
|||||||
'keywords',
|
'keywords',
|
||||||
'last_stocktake',
|
'last_stocktake',
|
||||||
'link',
|
'link',
|
||||||
|
'locked',
|
||||||
'minimum_stock',
|
'minimum_stock',
|
||||||
'name',
|
'name',
|
||||||
'notes',
|
'notes',
|
||||||
|
@ -303,3 +303,50 @@ class BomItemTest(TestCase):
|
|||||||
|
|
||||||
with self.assertRaises(django_exceptions.ValidationError):
|
with self.assertRaises(django_exceptions.ValidationError):
|
||||||
BomItem.objects.create(part=part_v, sub_part=part_a, quantity=10)
|
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')
|
param = prt.get_parameter('Not a parameter')
|
||||||
self.assertIsNone(param)
|
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):
|
class TestCategoryTemplates(TransactionTestCase):
|
||||||
"""Test class for PartCategoryParameterTemplate model."""
|
"""Test class for PartCategoryParameterTemplate model."""
|
||||||
|
@ -371,6 +371,24 @@ class PartTest(TestCase):
|
|||||||
self.assertIsNotNone(p.last_stocktake)
|
self.assertIsNotNone(p.last_stocktake)
|
||||||
self.assertEqual(p.last_stocktake, ps.date)
|
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):
|
class TestTemplateTest(TestCase):
|
||||||
"""Unit test for the TestTemplate class."""
|
"""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_REFERENCE_PATTERN" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="BUILDORDER_REQUIRE_RESPONSIBLE" %}
|
{% 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_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="BUILDORDER_REQUIRE_VALID_BOM" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS" %}
|
{% include "InvenTree/settings/setting.html" with key="PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS" %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
@ -1311,7 +1311,7 @@ function loadBomTable(table, options={}) {
|
|||||||
|
|
||||||
return renderLink(
|
return renderLink(
|
||||||
'{% trans "View BOM" %}',
|
'{% trans "View BOM" %}',
|
||||||
`/part/${row.part}/bom/`
|
`/part/${row.part}/?display=bom`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -25,13 +25,6 @@
|
|||||||
printLabels,
|
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:
|
* 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 editing a part, we can set the "active" status
|
||||||
if (options.edit) {
|
if (options.edit) {
|
||||||
fields.active = {
|
fields.active = {};
|
||||||
group: 'attributes'
|
fields.locked = {};
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pop 'expiry' field
|
// Pop 'expiry' field
|
||||||
@ -815,6 +814,10 @@ function makePartIcons(part) {
|
|||||||
html += `<span class='badge badge-right rounded-pill bg-warning'>{% trans "Inactive" %}</span> `;
|
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;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -716,6 +716,11 @@ function getPartTableFilters() {
|
|||||||
title: '{% trans "Active" %}',
|
title: '{% trans "Active" %}',
|
||||||
description: '{% trans "Show active parts" %}',
|
description: '{% trans "Show active parts" %}',
|
||||||
},
|
},
|
||||||
|
locked: {
|
||||||
|
type: 'bool',
|
||||||
|
title: '{% trans "Locked" %}',
|
||||||
|
description: '{% trans "Show locked parts" %}',
|
||||||
|
},
|
||||||
assembly: {
|
assembly: {
|
||||||
type: 'bool',
|
type: 'bool',
|
||||||
title: '{% trans "Assembly" %}',
|
title: '{% trans "Assembly" %}',
|
||||||
|
@ -24,15 +24,17 @@ export function PartIcons({ part }: { part: any }) {
|
|||||||
return (
|
return (
|
||||||
<td colSpan={2}>
|
<td colSpan={2}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
<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 && (
|
{!part.active && (
|
||||||
<Tooltip label={t`Part is not active`}>
|
<Tooltip label={t`Part is not active`}>
|
||||||
<Badge color="red" variant="filled">
|
<Badge color="red" variant="filled">
|
||||||
<div
|
<Trans>Inactive</Trans>
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: '5px' }}
|
|
||||||
>
|
|
||||||
<InvenTreeIcon icon="inactive" iconProps={{ size: 19 }} />{' '}
|
|
||||||
<Trans>Inactive</Trans>
|
|
||||||
</div>
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
@ -192,8 +192,8 @@ export function ApiFormField({
|
|||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
// Coerce the value to a (stringified) boolean value
|
// Coerce the value to a (stringified) boolean value
|
||||||
const booleanValue: string = useMemo(() => {
|
const booleanValue: boolean = useMemo(() => {
|
||||||
return isTrue(value).toString();
|
return isTrue(value);
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
// Construct the individual field
|
// Construct the individual field
|
||||||
@ -232,13 +232,12 @@ export function ApiFormField({
|
|||||||
return (
|
return (
|
||||||
<Switch
|
<Switch
|
||||||
{...reducedDefinition}
|
{...reducedDefinition}
|
||||||
value={booleanValue}
|
checked={booleanValue}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
id={fieldId}
|
id={fieldId}
|
||||||
aria-label={`boolean-field-${field.name}`}
|
aria-label={`boolean-field-${field.name}`}
|
||||||
radius="lg"
|
radius="lg"
|
||||||
size="sm"
|
size="sm"
|
||||||
checked={isTrue(reducedDefinition.value)}
|
|
||||||
error={error?.message}
|
error={error?.message}
|
||||||
onChange={(event) => onChange(event.currentTarget.checked)}
|
onChange={(event) => onChange(event.currentTarget.checked)}
|
||||||
/>
|
/>
|
||||||
|
@ -46,6 +46,7 @@ export function usePartFields({
|
|||||||
purchaseable: {},
|
purchaseable: {},
|
||||||
salable: {},
|
salable: {},
|
||||||
virtual: {},
|
virtual: {},
|
||||||
|
locked: {},
|
||||||
active: {}
|
active: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -38,6 +38,7 @@ import {
|
|||||||
IconLink,
|
IconLink,
|
||||||
IconList,
|
IconList,
|
||||||
IconListTree,
|
IconListTree,
|
||||||
|
IconLock,
|
||||||
IconMail,
|
IconMail,
|
||||||
IconMapPin,
|
IconMapPin,
|
||||||
IconMapPinHeart,
|
IconMapPinHeart,
|
||||||
@ -152,6 +153,8 @@ const icons = {
|
|||||||
inactive: IconX,
|
inactive: IconX,
|
||||||
part: IconBox,
|
part: IconBox,
|
||||||
supplier_part: IconPackageImport,
|
supplier_part: IconPackageImport,
|
||||||
|
lock: IconLock,
|
||||||
|
locked: IconLock,
|
||||||
|
|
||||||
calendar: IconCalendar,
|
calendar: IconCalendar,
|
||||||
external: IconExternalLink,
|
external: IconExternalLink,
|
||||||
|
@ -246,6 +246,7 @@ export default function SystemSettings() {
|
|||||||
'BUILDORDER_REFERENCE_PATTERN',
|
'BUILDORDER_REFERENCE_PATTERN',
|
||||||
'BUILDORDER_REQUIRE_RESPONSIBLE',
|
'BUILDORDER_REQUIRE_RESPONSIBLE',
|
||||||
'BUILDORDER_REQUIRE_ACTIVE_PART',
|
'BUILDORDER_REQUIRE_ACTIVE_PART',
|
||||||
|
'BUILDORDER_REQUIRE_LOCKED_PART',
|
||||||
'BUILDORDER_REQUIRE_VALID_BOM',
|
'BUILDORDER_REQUIRE_VALID_BOM',
|
||||||
'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS'
|
'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS'
|
||||||
]}
|
]}
|
||||||
|
@ -260,6 +260,11 @@ export default function PartDetail() {
|
|||||||
name: 'active',
|
name: 'active',
|
||||||
label: t`Active`
|
label: t`Active`
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'boolean',
|
||||||
|
name: 'locked',
|
||||||
|
label: t`Locked`
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
name: 'template',
|
name: 'template',
|
||||||
@ -485,7 +490,12 @@ export default function PartDetail() {
|
|||||||
name: 'parameters',
|
name: 'parameters',
|
||||||
label: t`Parameters`,
|
label: t`Parameters`,
|
||||||
icon: <IconList />,
|
icon: <IconList />,
|
||||||
content: <PartParameterTable partId={id ?? -1} />
|
content: (
|
||||||
|
<PartParameterTable
|
||||||
|
partId={id ?? -1}
|
||||||
|
partLocked={part?.locked == true}
|
||||||
|
/>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'stock',
|
name: 'stock',
|
||||||
@ -520,7 +530,9 @@ export default function PartDetail() {
|
|||||||
label: t`Bill of Materials`,
|
label: t`Bill of Materials`,
|
||||||
icon: <IconListTree />,
|
icon: <IconListTree />,
|
||||||
hidden: !part.assembly,
|
hidden: !part.assembly,
|
||||||
content: <BomTable partId={part.pk ?? -1} />
|
content: (
|
||||||
|
<BomTable partId={part.pk ?? -1} partLocked={part?.locked == true} />
|
||||||
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'builds',
|
name: 'builds',
|
||||||
@ -681,6 +693,12 @@ export default function PartDetail() {
|
|||||||
visible={part.building > 0}
|
visible={part.building > 0}
|
||||||
key="in_production"
|
key="in_production"
|
||||||
/>,
|
/>,
|
||||||
|
<DetailsBadge
|
||||||
|
label={t`Locked`}
|
||||||
|
color="black"
|
||||||
|
visible={part.locked}
|
||||||
|
key="locked"
|
||||||
|
/>,
|
||||||
<DetailsBadge
|
<DetailsBadge
|
||||||
label={t`Inactive`}
|
label={t`Inactive`}
|
||||||
color="red"
|
color="red"
|
||||||
|
@ -2,7 +2,8 @@
|
|||||||
* Common rendering functions for table column data.
|
* Common rendering functions for table column data.
|
||||||
*/
|
*/
|
||||||
import { t } from '@lingui/macro';
|
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 { YesNoButton } from '../components/buttons/YesNoButton';
|
||||||
import { Thumbnail } from '../components/images/Thumbnail';
|
import { Thumbnail } from '../components/images/Thumbnail';
|
||||||
@ -19,10 +20,24 @@ import { ProjectCodeHoverCard } from './TableHoverCard';
|
|||||||
// Render a Part instance within a table
|
// Render a Part instance within a table
|
||||||
export function PartColumn(part: any, full_name?: boolean) {
|
export function PartColumn(part: any, full_name?: boolean) {
|
||||||
return part ? (
|
return part ? (
|
||||||
<Thumbnail
|
<Group justify="space-between" wrap="nowrap">
|
||||||
src={part?.thumbnail ?? part?.image}
|
<Thumbnail
|
||||||
text={full_name ? part?.full_name : part?.name}
|
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 />
|
<Skeleton />
|
||||||
);
|
);
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import { t } from '@lingui/macro';
|
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 { showNotification } from '@mantine/notifications';
|
||||||
import {
|
import {
|
||||||
IconArrowRight,
|
IconArrowRight,
|
||||||
IconCircleCheck,
|
IconCircleCheck,
|
||||||
|
IconLock,
|
||||||
IconSwitch3
|
IconSwitch3
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { ReactNode, useCallback, useMemo, useState } from 'react';
|
import { ReactNode, useCallback, useMemo, useState } from 'react';
|
||||||
@ -56,9 +57,11 @@ function availableStockQuantity(record: any): number {
|
|||||||
|
|
||||||
export function BomTable({
|
export function BomTable({
|
||||||
partId,
|
partId,
|
||||||
|
partLocked,
|
||||||
params = {}
|
params = {}
|
||||||
}: {
|
}: {
|
||||||
partId: number;
|
partId: number;
|
||||||
|
partLocked?: boolean;
|
||||||
params?: any;
|
params?: any;
|
||||||
}) {
|
}) {
|
||||||
const user = useUserState();
|
const user = useUserState();
|
||||||
@ -384,12 +387,15 @@ export function BomTable({
|
|||||||
{
|
{
|
||||||
title: t`Validate BOM Line`,
|
title: t`Validate BOM Line`,
|
||||||
color: 'green',
|
color: 'green',
|
||||||
hidden: record.validated || !user.hasChangeRole(UserRoles.part),
|
hidden:
|
||||||
|
partLocked ||
|
||||||
|
record.validated ||
|
||||||
|
!user.hasChangeRole(UserRoles.part),
|
||||||
icon: <IconCircleCheck />,
|
icon: <IconCircleCheck />,
|
||||||
onClick: () => validateBomItem(record)
|
onClick: () => validateBomItem(record)
|
||||||
},
|
},
|
||||||
RowEditAction({
|
RowEditAction({
|
||||||
hidden: !user.hasChangeRole(UserRoles.part),
|
hidden: partLocked || !user.hasChangeRole(UserRoles.part),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
setSelectedBomItem(record.pk);
|
setSelectedBomItem(record.pk);
|
||||||
editBomItem.open();
|
editBomItem.open();
|
||||||
@ -398,11 +404,11 @@ export function BomTable({
|
|||||||
{
|
{
|
||||||
title: t`Edit Substitutes`,
|
title: t`Edit Substitutes`,
|
||||||
color: 'blue',
|
color: 'blue',
|
||||||
hidden: !user.hasChangeRole(UserRoles.part),
|
hidden: partLocked || !user.hasChangeRole(UserRoles.part),
|
||||||
icon: <IconSwitch3 />
|
icon: <IconSwitch3 />
|
||||||
},
|
},
|
||||||
RowDeleteAction({
|
RowDeleteAction({
|
||||||
hidden: !user.hasDeleteRole(UserRoles.part),
|
hidden: partLocked || !user.hasDeleteRole(UserRoles.part),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
setSelectedBomItem(record.pk);
|
setSelectedBomItem(record.pk);
|
||||||
deleteBomItem.open();
|
deleteBomItem.open();
|
||||||
@ -410,44 +416,56 @@ export function BomTable({
|
|||||||
})
|
})
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
[partId, user]
|
[partId, partLocked, user]
|
||||||
);
|
);
|
||||||
|
|
||||||
const tableActions = useMemo(() => {
|
const tableActions = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
<AddItemButton
|
<AddItemButton
|
||||||
hidden={!user.hasAddRole(UserRoles.part)}
|
hidden={partLocked || !user.hasAddRole(UserRoles.part)}
|
||||||
tooltip={t`Add BOM Item`}
|
tooltip={t`Add BOM Item`}
|
||||||
onClick={() => newBomItem.open()}
|
onClick={() => newBomItem.open()}
|
||||||
/>
|
/>
|
||||||
];
|
];
|
||||||
}, [user]);
|
}, [partLocked, user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{newBomItem.modal}
|
{newBomItem.modal}
|
||||||
{editBomItem.modal}
|
{editBomItem.modal}
|
||||||
{deleteBomItem.modal}
|
{deleteBomItem.modal}
|
||||||
<InvenTreeTable
|
<Stack gap="xs">
|
||||||
url={apiUrl(ApiEndpoints.bom_list)}
|
{partLocked && (
|
||||||
tableState={table}
|
<Alert
|
||||||
columns={tableColumns}
|
title={t`Part is Locked`}
|
||||||
props={{
|
color="red"
|
||||||
params: {
|
icon={<IconLock />}
|
||||||
...params,
|
p="xs"
|
||||||
part: partId,
|
>
|
||||||
part_detail: true,
|
<Text>{t`Bill of materials cannot be edited, as the part is locked`}</Text>
|
||||||
sub_part_detail: true
|
</Alert>
|
||||||
},
|
)}
|
||||||
tableActions: tableActions,
|
<InvenTreeTable
|
||||||
tableFilters: tableFilters,
|
url={apiUrl(ApiEndpoints.bom_list)}
|
||||||
modelType: ModelType.part,
|
tableState={table}
|
||||||
modelField: 'sub_part',
|
columns={tableColumns}
|
||||||
rowActions: rowActions,
|
props={{
|
||||||
enableSelection: true,
|
params: {
|
||||||
enableBulkDelete: true
|
...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 { 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 { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||||
@ -25,7 +26,13 @@ import { TableHoverCard } from '../TableHoverCard';
|
|||||||
/**
|
/**
|
||||||
* Construct a table listing parameters for a given part
|
* 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 table = useTable('part-parameters');
|
||||||
|
|
||||||
const user = useUserState();
|
const user = useUserState();
|
||||||
@ -142,7 +149,7 @@ export function PartParameterTable({ partId }: { partId: any }) {
|
|||||||
return [
|
return [
|
||||||
RowEditAction({
|
RowEditAction({
|
||||||
tooltip: t`Edit Part Parameter`,
|
tooltip: t`Edit Part Parameter`,
|
||||||
hidden: !user.hasChangeRole(UserRoles.part),
|
hidden: partLocked || !user.hasChangeRole(UserRoles.part),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
setSelectedParameter(record.pk);
|
setSelectedParameter(record.pk);
|
||||||
editParameter.open();
|
editParameter.open();
|
||||||
@ -150,7 +157,7 @@ export function PartParameterTable({ partId }: { partId: any }) {
|
|||||||
}),
|
}),
|
||||||
RowDeleteAction({
|
RowDeleteAction({
|
||||||
tooltip: t`Delete Part Parameter`,
|
tooltip: t`Delete Part Parameter`,
|
||||||
hidden: !user.hasDeleteRole(UserRoles.part),
|
hidden: partLocked || !user.hasDeleteRole(UserRoles.part),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
setSelectedParameter(record.pk);
|
setSelectedParameter(record.pk);
|
||||||
deleteParameter.open();
|
deleteParameter.open();
|
||||||
@ -158,7 +165,7 @@ export function PartParameterTable({ partId }: { partId: any }) {
|
|||||||
})
|
})
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
[partId, user]
|
[partId, partLocked, user]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Custom table actions
|
// Custom table actions
|
||||||
@ -166,40 +173,52 @@ export function PartParameterTable({ partId }: { partId: any }) {
|
|||||||
return [
|
return [
|
||||||
<AddItemButton
|
<AddItemButton
|
||||||
key="add-parameter"
|
key="add-parameter"
|
||||||
hidden={!user.hasAddRole(UserRoles.part)}
|
hidden={partLocked || !user.hasAddRole(UserRoles.part)}
|
||||||
tooltip={t`Add parameter`}
|
tooltip={t`Add parameter`}
|
||||||
onClick={() => newParameter.open()}
|
onClick={() => newParameter.open()}
|
||||||
/>
|
/>
|
||||||
];
|
];
|
||||||
}, [user]);
|
}, [partLocked, user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{newParameter.modal}
|
{newParameter.modal}
|
||||||
{editParameter.modal}
|
{editParameter.modal}
|
||||||
{deleteParameter.modal}
|
{deleteParameter.modal}
|
||||||
<InvenTreeTable
|
<Stack gap="xs">
|
||||||
url={apiUrl(ApiEndpoints.part_parameter_list)}
|
{partLocked && (
|
||||||
tableState={table}
|
<Alert
|
||||||
columns={tableColumns}
|
title={t`Part is Locked`}
|
||||||
props={{
|
color="red"
|
||||||
rowActions: rowActions,
|
icon={<IconLock />}
|
||||||
enableDownload: true,
|
p="xs"
|
||||||
tableActions: tableActions,
|
>
|
||||||
tableFilters: [
|
<Text>{t`Part parameters cannot be edited, as the part is locked`}</Text>
|
||||||
{
|
</Alert>
|
||||||
name: 'include_variants',
|
)}
|
||||||
label: t`Include Variants`,
|
<InvenTreeTable
|
||||||
type: 'boolean'
|
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,
|
</Stack>
|
||||||
template_detail: true,
|
|
||||||
part_detail: true
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -25,6 +25,7 @@ function partTableColumns(): TableColumn[] {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
accessor: 'name',
|
accessor: 'name',
|
||||||
|
title: t`Part`,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
noWrap: true,
|
noWrap: true,
|
||||||
render: (record: any) => PartColumn(record)
|
render: (record: any) => PartColumn(record)
|
||||||
@ -169,6 +170,12 @@ function partTableFilters(): TableFilter[] {
|
|||||||
description: t`Filter by part active status`,
|
description: t`Filter by part active status`,
|
||||||
type: 'boolean'
|
type: 'boolean'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'locked',
|
||||||
|
label: t`Locked`,
|
||||||
|
description: t`Filter by part locked status`,
|
||||||
|
type: 'boolean'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'assembly',
|
name: 'assembly',
|
||||||
label: t`Assembly`,
|
label: t`Assembly`,
|
||||||
|
@ -2,6 +2,27 @@ import { test } from '../baseFixtures';
|
|||||||
import { baseUrl } from '../defaults';
|
import { baseUrl } from '../defaults';
|
||||||
import { doQuickLogin } from '../login';
|
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 }) => {
|
test('PUI - Pages - Part - Pricing (Nothing, BOM)', async ({ page }) => {
|
||||||
await doQuickLogin(page);
|
await doQuickLogin(page);
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user