2
0
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:
Oliver
2026-05-19 17:06:05 +10:00
committed by GitHub
parent cd5bd3c245
commit 9cac925e91
7 changed files with 127 additions and 76 deletions
@@ -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'),
+86 -76
View File
@@ -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()
+30
View File
@@ -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.