diff --git a/docs/docs/settings/global.md b/docs/docs/settings/global.md index d936c62244..f42f19a906 100644 --- a/docs/docs/settings/global.md +++ b/docs/docs/settings/global.md @@ -209,6 +209,7 @@ Configuration of stock item options | Name | Description | Default | Units | | ---- | ----------- | ------- | ----- | {{ globalsetting("SERIAL_NUMBER_GLOBALLY_UNIQUE") }} +{{ globalsetting("STOCK_ALLOW_EDIT_SERIAL") }} {{ globalsetting("STOCK_ALLOW_DELETE_SERIALIZED") }} {{ globalsetting("STOCK_DELETE_DEPLETED_DEFAULT") }} {{ globalsetting("STOCK_BATCH_CODE_TEMPLATE") }} diff --git a/docs/docs/stock/traceability.md b/docs/docs/stock/traceability.md index 5fb21aa608..666cc84de1 100644 --- a/docs/docs/stock/traceability.md +++ b/docs/docs/stock/traceability.md @@ -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") }} +!!! 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 diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py index 8c6c15fea2..394745db82 100644 --- a/src/backend/InvenTree/common/setting/system.py +++ b/src/backend/InvenTree/common/setting/system.py @@ -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'), diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 2c8e4d48bb..51daa040ce 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -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() diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index 2ac10c75b8..060eef57f7 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -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. diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index 338b65e246..547d2b2087 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -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 || diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx index c3720a49ea..a79d501087 100644 --- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx +++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx @@ -251,6 +251,7 @@ export default function SystemSettings() {