mirror of
https://github.com/inventree/InvenTree.git
synced 2026-05-22 01:06:50 +00:00
Prevent edit serial (#11964)
* Add new global setting * Run serial number validation check * Disable field in frontend * Add unit test * Docs tweak
This commit is contained in:
@@ -696,6 +696,12 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
'STOCK_ALLOW_EDIT_SERIAL': {
|
||||
'name': _('Allow Edit Serial Number'),
|
||||
'description': _('Allow editing of serial number for stock items'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
'STOCK_ALLOW_DELETE_SERIALIZED': {
|
||||
'name': _('Delete Serialized Stock'),
|
||||
'description': _('Allow deletion of stock items which have a serial number'),
|
||||
|
||||
@@ -449,6 +449,81 @@ class StockItem(
|
||||
|
||||
order_insertion_by = ['part']
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Save this StockItem to the database.
|
||||
|
||||
Performs a number of checks:
|
||||
- Unique serial number requirement
|
||||
- Adds a transaction note when the item is first created.
|
||||
"""
|
||||
self.validate_unique()
|
||||
self.clean()
|
||||
self.update_serial_number()
|
||||
|
||||
user = kwargs.pop('user', None)
|
||||
|
||||
if user is None:
|
||||
user = getattr(self, '_user', None)
|
||||
|
||||
# If 'add_note = False' specified, then no tracking note will be added for item creation
|
||||
add_note = kwargs.pop('add_note', True)
|
||||
|
||||
notes = kwargs.pop('notes', '')
|
||||
|
||||
if self.pk:
|
||||
# StockItem has already been saved
|
||||
|
||||
# Check if "interesting" fields have been changed
|
||||
# (we wish to record these as historical records)
|
||||
|
||||
try:
|
||||
old = StockItem.objects.get(pk=self.pk)
|
||||
old_custom_status = old.get_custom_status()
|
||||
custom_status = self.get_custom_status()
|
||||
|
||||
deltas = {}
|
||||
|
||||
# Status changed?
|
||||
if old.status != self.status:
|
||||
# Custom status changed?
|
||||
# Matches custom status tracking behavior of StockChangeStatusSerializer
|
||||
if old_custom_status != custom_status:
|
||||
deltas['status'] = custom_status
|
||||
deltas['status_logical'] = self.status
|
||||
else:
|
||||
deltas['status'] = self.status
|
||||
deltas['status_logical'] = self.status
|
||||
|
||||
if old_custom_status:
|
||||
deltas['old_status'] = old_custom_status
|
||||
deltas['old_status_logical'] = old.status
|
||||
else:
|
||||
deltas['old_status'] = old.status
|
||||
deltas['old_status_logical'] = old.status
|
||||
|
||||
if add_note and len(deltas) > 0:
|
||||
self.add_tracking_entry(
|
||||
StockHistoryCode.EDITED, user, deltas=deltas, notes=notes
|
||||
)
|
||||
|
||||
except (ValueError, StockItem.DoesNotExist):
|
||||
pass
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# If user information is provided, and no existing note exists, create one!
|
||||
if add_note and self.tracking_info.count() == 0:
|
||||
tracking_info = {'status': self.status}
|
||||
|
||||
self.add_tracking_entry(
|
||||
StockHistoryCode.CREATED,
|
||||
user,
|
||||
deltas=tracking_info,
|
||||
notes=notes,
|
||||
location=self.location,
|
||||
quantity=float(self.quantity),
|
||||
)
|
||||
|
||||
def delete(self, ignore_serial_check: bool = False, **kwargs):
|
||||
"""Custom delete method for StockItem model.
|
||||
|
||||
@@ -784,82 +859,6 @@ class StockItem(
|
||||
"""Return the 'previous' stock item (based on serial number)."""
|
||||
return self.get_next_serialized_item(reverse=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Save this StockItem to the database.
|
||||
|
||||
Performs a number of checks:
|
||||
- Unique serial number requirement
|
||||
- Adds a transaction note when the item is first created.
|
||||
"""
|
||||
self.validate_unique()
|
||||
self.clean()
|
||||
|
||||
self.update_serial_number()
|
||||
|
||||
user = kwargs.pop('user', None)
|
||||
|
||||
if user is None:
|
||||
user = getattr(self, '_user', None)
|
||||
|
||||
# If 'add_note = False' specified, then no tracking note will be added for item creation
|
||||
add_note = kwargs.pop('add_note', True)
|
||||
|
||||
notes = kwargs.pop('notes', '')
|
||||
|
||||
if self.pk:
|
||||
# StockItem has already been saved
|
||||
|
||||
# Check if "interesting" fields have been changed
|
||||
# (we wish to record these as historical records)
|
||||
|
||||
try:
|
||||
old = StockItem.objects.get(pk=self.pk)
|
||||
old_custom_status = old.get_custom_status()
|
||||
custom_status = self.get_custom_status()
|
||||
|
||||
deltas = {}
|
||||
|
||||
# Status changed?
|
||||
if old.status != self.status:
|
||||
# Custom status changed?
|
||||
# Matches custom status tracking behavior of StockChangeStatusSerializer
|
||||
if old_custom_status != custom_status:
|
||||
deltas['status'] = custom_status
|
||||
deltas['status_logical'] = self.status
|
||||
else:
|
||||
deltas['status'] = self.status
|
||||
deltas['status_logical'] = self.status
|
||||
|
||||
if old_custom_status:
|
||||
deltas['old_status'] = old_custom_status
|
||||
deltas['old_status_logical'] = old.status
|
||||
else:
|
||||
deltas['old_status'] = old.status
|
||||
deltas['old_status_logical'] = old.status
|
||||
|
||||
if add_note and len(deltas) > 0:
|
||||
self.add_tracking_entry(
|
||||
StockHistoryCode.EDITED, user, deltas=deltas, notes=notes
|
||||
)
|
||||
|
||||
except (ValueError, StockItem.DoesNotExist):
|
||||
pass
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# If user information is provided, and no existing note exists, create one!
|
||||
if add_note and self.tracking_info.count() == 0:
|
||||
tracking_info = {'status': self.status}
|
||||
|
||||
self.add_tracking_entry(
|
||||
StockHistoryCode.CREATED,
|
||||
user,
|
||||
deltas=tracking_info,
|
||||
notes=notes,
|
||||
location=self.location,
|
||||
quantity=float(self.quantity),
|
||||
)
|
||||
|
||||
@property
|
||||
def status_label(self):
|
||||
"""Return label."""
|
||||
@@ -936,6 +935,17 @@ class StockItem(
|
||||
if type(self.batch) is str:
|
||||
self.batch = self.batch.strip()
|
||||
|
||||
if not get_global_setting('STOCK_ALLOW_EDIT_SERIAL'):
|
||||
deltas = self.get_field_deltas()
|
||||
|
||||
# Prevent editing of serial numbers if the item already has a serial number assigned
|
||||
if 'serial' in deltas and deltas['serial']['old'] not in [None, '']:
|
||||
raise ValidationError({
|
||||
'serial': _(
|
||||
'Editing of serial numbers is not allowed - this item has already been assigned a serial number'
|
||||
)
|
||||
})
|
||||
|
||||
# Custom validation of batch code
|
||||
self.validate_batch_code()
|
||||
|
||||
|
||||
@@ -1644,6 +1644,36 @@ class StockItemTest(StockAPITestCase):
|
||||
)
|
||||
self.assertEqual(trackable_part.get_stock_count(), 10)
|
||||
|
||||
def test_edit_serial(self):
|
||||
"""Test that we can edit serial numbers via the API."""
|
||||
item = StockItem.objects.create(
|
||||
part=Part.objects.filter(trackable=True).first(),
|
||||
quantity=1,
|
||||
location=StockLocation.objects.first(),
|
||||
)
|
||||
|
||||
set_global_setting('STOCK_ALLOW_EDIT_SERIAL', False)
|
||||
|
||||
url = reverse('api-stock-detail', kwargs={'pk': item.pk})
|
||||
|
||||
# Edit the serial number
|
||||
# This should succeed, as the initial serial number is blank
|
||||
response = self.patch(url, {'serial': '54321'}, expected_code=200)
|
||||
self.assertEqual(response.data['serial'], '54321')
|
||||
|
||||
# Edit it again - this time, should fail as the serial number is already set
|
||||
response = self.patch(url, {'serial': '98765'}, expected_code=400)
|
||||
self.assertIn('Editing of serial numbers is not allowed', str(response.data))
|
||||
|
||||
# Ensure that changing a different field does not cause an error
|
||||
response = self.patch(url, {'batch': 'abcde'}, expected_code=200)
|
||||
self.assertEqual(response.data['batch'], 'abcde')
|
||||
|
||||
# Adjust the setting to allow serial editing
|
||||
set_global_setting('STOCK_ALLOW_EDIT_SERIAL', True)
|
||||
response = self.patch(url, {'serial': '98765'}, expected_code=200)
|
||||
self.assertEqual(response.data['serial'], '98765')
|
||||
|
||||
def test_default_expiry(self):
|
||||
"""Test that the "default_expiry" functionality works via the API.
|
||||
|
||||
|
||||
@@ -232,6 +232,7 @@ export function useStockFields({
|
||||
serial: {
|
||||
placeholderAutofill: true,
|
||||
placeholder: serialGenerator.result,
|
||||
disabled: !create && !globalSettings.isSet('STOCK_ALLOW_EDIT_SERIAL'),
|
||||
hidden:
|
||||
create ||
|
||||
partInstance.trackable == false ||
|
||||
|
||||
@@ -251,6 +251,7 @@ export default function SystemSettings() {
|
||||
<GlobalSettingList
|
||||
keys={[
|
||||
'SERIAL_NUMBER_GLOBALLY_UNIQUE',
|
||||
'STOCK_ALLOW_EDIT_SERIAL',
|
||||
'STOCK_ALLOW_DELETE_SERIALIZED',
|
||||
'STOCK_DELETE_DEPLETED_DEFAULT',
|
||||
'STOCK_BATCH_CODE_TEMPLATE',
|
||||
|
||||
Reference in New Issue
Block a user