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:
@@ -209,6 +209,7 @@ Configuration of stock item options
|
|||||||
| Name | Description | Default | Units |
|
| Name | Description | Default | Units |
|
||||||
| ---- | ----------- | ------- | ----- |
|
| ---- | ----------- | ------- | ----- |
|
||||||
{{ globalsetting("SERIAL_NUMBER_GLOBALLY_UNIQUE") }}
|
{{ globalsetting("SERIAL_NUMBER_GLOBALLY_UNIQUE") }}
|
||||||
|
{{ globalsetting("STOCK_ALLOW_EDIT_SERIAL") }}
|
||||||
{{ globalsetting("STOCK_ALLOW_DELETE_SERIALIZED") }}
|
{{ globalsetting("STOCK_ALLOW_DELETE_SERIALIZED") }}
|
||||||
{{ globalsetting("STOCK_DELETE_DEPLETED_DEFAULT") }}
|
{{ globalsetting("STOCK_DELETE_DEPLETED_DEFAULT") }}
|
||||||
{{ globalsetting("STOCK_BATCH_CODE_TEMPLATE") }}
|
{{ globalsetting("STOCK_BATCH_CODE_TEMPLATE") }}
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ Note that any serial number adjustments are subject to the same validation rules
|
|||||||
|
|
||||||
{{ image("stock/serial_edit_error.png", title="Error while editing a serial number") }}
|
{{ image("stock/serial_edit_error.png", title="Error while editing a serial number") }}
|
||||||
|
|
||||||
|
!!! info "Disable Serial Number Editing"
|
||||||
|
If you wish to prevent users from editing serial numbers, this can be achieved by disabling the `Allow Edit Serial Number` setting in the system settings view.
|
||||||
|
|
||||||
#### Plugin Support
|
#### Plugin Support
|
||||||
|
|
||||||
|
|||||||
@@ -696,6 +696,12 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
|||||||
'default': True,
|
'default': True,
|
||||||
'validator': bool,
|
'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': {
|
'STOCK_ALLOW_DELETE_SERIALIZED': {
|
||||||
'name': _('Delete Serialized Stock'),
|
'name': _('Delete Serialized Stock'),
|
||||||
'description': _('Allow deletion of stock items which have a serial number'),
|
'description': _('Allow deletion of stock items which have a serial number'),
|
||||||
|
|||||||
@@ -449,6 +449,81 @@ class StockItem(
|
|||||||
|
|
||||||
order_insertion_by = ['part']
|
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):
|
def delete(self, ignore_serial_check: bool = False, **kwargs):
|
||||||
"""Custom delete method for StockItem model.
|
"""Custom delete method for StockItem model.
|
||||||
|
|
||||||
@@ -784,82 +859,6 @@ class StockItem(
|
|||||||
"""Return the 'previous' stock item (based on serial number)."""
|
"""Return the 'previous' stock item (based on serial number)."""
|
||||||
return self.get_next_serialized_item(reverse=True)
|
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
|
@property
|
||||||
def status_label(self):
|
def status_label(self):
|
||||||
"""Return label."""
|
"""Return label."""
|
||||||
@@ -936,6 +935,17 @@ class StockItem(
|
|||||||
if type(self.batch) is str:
|
if type(self.batch) is str:
|
||||||
self.batch = self.batch.strip()
|
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
|
# Custom validation of batch code
|
||||||
self.validate_batch_code()
|
self.validate_batch_code()
|
||||||
|
|
||||||
|
|||||||
@@ -1644,6 +1644,36 @@ class StockItemTest(StockAPITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(trackable_part.get_stock_count(), 10)
|
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):
|
def test_default_expiry(self):
|
||||||
"""Test that the "default_expiry" functionality works via the API.
|
"""Test that the "default_expiry" functionality works via the API.
|
||||||
|
|
||||||
|
|||||||
@@ -232,6 +232,7 @@ export function useStockFields({
|
|||||||
serial: {
|
serial: {
|
||||||
placeholderAutofill: true,
|
placeholderAutofill: true,
|
||||||
placeholder: serialGenerator.result,
|
placeholder: serialGenerator.result,
|
||||||
|
disabled: !create && !globalSettings.isSet('STOCK_ALLOW_EDIT_SERIAL'),
|
||||||
hidden:
|
hidden:
|
||||||
create ||
|
create ||
|
||||||
partInstance.trackable == false ||
|
partInstance.trackable == false ||
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ export default function SystemSettings() {
|
|||||||
<GlobalSettingList
|
<GlobalSettingList
|
||||||
keys={[
|
keys={[
|
||||||
'SERIAL_NUMBER_GLOBALLY_UNIQUE',
|
'SERIAL_NUMBER_GLOBALLY_UNIQUE',
|
||||||
|
'STOCK_ALLOW_EDIT_SERIAL',
|
||||||
'STOCK_ALLOW_DELETE_SERIALIZED',
|
'STOCK_ALLOW_DELETE_SERIALIZED',
|
||||||
'STOCK_DELETE_DEPLETED_DEFAULT',
|
'STOCK_DELETE_DEPLETED_DEFAULT',
|
||||||
'STOCK_BATCH_CODE_TEMPLATE',
|
'STOCK_BATCH_CODE_TEMPLATE',
|
||||||
|
|||||||
Reference in New Issue
Block a user