2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-17 04:25:42 +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:
Oliver
2024-07-07 11:35:30 +10:00
committed by GitHub
parent 97b6258797
commit 8309eb628f
30 changed files with 448 additions and 119 deletions

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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."""

View File

@ -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'),

View File

@ -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."""

View 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'),
),
]

View File

@ -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(

View File

@ -632,6 +632,7 @@ class PartSerializer(
'keywords',
'last_stocktake',
'link',
'locked',
'minimum_stock',
'name',
'notes',

View File

@ -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()

View File

@ -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."""

View File

@ -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."""

View File

@ -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>

View File

@ -1311,7 +1311,7 @@ function loadBomTable(table, options={}) {
return renderLink(
'{% trans "View BOM" %}',
`/part/${row.part}/bom/`
`/part/${row.part}/?display=bom`
);
}
},

View File

@ -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:

View File

@ -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;
}

View File

@ -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" %}',