From a5895adcac3c24ce3efad6b380b0a11e2bf4c21c Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 17 Oct 2026 11:26:57 +1100 Subject: [PATCH] [bug] Handle length limitation for "notes" fields (#13023) * [bug] Handle length limitation for "notes" fields * Bump API version * Fix typing * Further typing fixes --- .../InvenTree/InvenTree/api_version.py | 6 +- src/backend/InvenTree/InvenTree/helpers.py | 11 +++- src/backend/InvenTree/InvenTree/tests.py | 10 +++ src/backend/InvenTree/build/serializers.py | 10 ++- src/backend/InvenTree/build/test_api.py | 17 +++++ src/backend/InvenTree/order/serializers.py | 6 ++ src/backend/InvenTree/order/test_api.py | 64 ++++++++++++++++++- src/backend/InvenTree/stock/models.py | 4 +- src/backend/InvenTree/stock/serializers.py | 8 +++ src/backend/InvenTree/stock/test_api.py | 46 +++++++++++++ 10 files changed, 177 insertions(+), 5 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 51d49770ac..b5f424848c 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,15 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 558 +INVENTREE_API_VERSION = 559 + """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v559 -> 2026-10-17 : https://github.com/inventree/InvenTree/pull/13023 + - Enforce maximum length for "notes" field in stock-adjustment API endpoints + v558 -> 2026-10-15 : https://github.com/inventree/InvenTree/pull/9768 - Adds "receiving_company" and "delivery_address" fields to the PurchaseOrder API endpoint diff --git a/src/backend/InvenTree/InvenTree/helpers.py b/src/backend/InvenTree/InvenTree/helpers.py index 75b0882544..3c6c075daa 100644 --- a/src/backend/InvenTree/InvenTree/helpers.py +++ b/src/backend/InvenTree/InvenTree/helpers.py @@ -637,6 +637,10 @@ def extract_serial_numbers( input_string = input_string.replace('~', str(next_value), 1) next_value = increment_serial_number(next_value, part=part) + from stock.models import StockItem + + serial_max_length = int(StockItem._meta.get_field('serial').max_length) + # Split input string by whitespace or comma (,) characters groups = re.split(r'[\s,]+', input_string) @@ -656,7 +660,12 @@ def extract_serial_numbers( if len(serial) == 0: return - if serial in serials: + if len(serial) > serial_max_length: + add_error( + _('Serial number exceeds maximum length') + + f' ({serial_max_length}): {serial[:20]}...' + ) + elif serial in serials: add_error(_('Duplicate serial') + f': {serial}') else: serials.append(serial) diff --git a/src/backend/InvenTree/InvenTree/tests.py b/src/backend/InvenTree/InvenTree/tests.py index 7d0ee83507..3be60551dc 100644 --- a/src/backend/InvenTree/InvenTree/tests.py +++ b/src/backend/InvenTree/InvenTree/tests.py @@ -1046,6 +1046,16 @@ class TestSerialNumberExtraction(TestCase): with self.assertRaises(ValidationError): e('1,2,3', 5, 1) + # Test serial number exceeding maximum length + with self.assertRaises(ValidationError) as exc: + e('1,' + 'x' * 101, 2, 1) + + self.assertIn('Serial number exceeds maximum length', str(exc.exception)) + + # Generated serials are also checked + with self.assertRaises(ValidationError): + e('x' * 100 + '9+', 2, 1) + # Test empty string with self.assertRaises(ValidationError): e(', , ,', 0, 1) diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 96799e7346..7f7708c09f 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -400,6 +400,7 @@ class BuildOutputCreateSerializer(serializers.Serializer): return quantity batch_code = serializers.CharField( + max_length=StockItem._meta.get_field('batch').max_length, required=False, allow_blank=True, default=generate_batch_code, @@ -535,6 +536,7 @@ class BuildOutputScrapSerializer(serializers.Serializer): notes = serializers.CharField( label=_('Notes'), + max_length=stock_models.StockItemTracking.NOTES_MAX_LENGTH, help_text=_('Reason for scrapping build output(s)'), required=True, allow_blank=False, @@ -595,7 +597,12 @@ class BuildOutputCompleteSerializer(serializers.Serializer): help_text=_('Complete outputs if stock has not been fully allocated'), ) - notes = serializers.CharField(label=_('Notes'), required=False, allow_blank=True) + notes = serializers.CharField( + label=_('Notes'), + max_length=stock_models.StockItemTracking.NOTES_MAX_LENGTH, + required=False, + allow_blank=True, + ) def validate(self, data): """Perform data validation for this serializer.""" @@ -1779,6 +1786,7 @@ class BuildConsumeSerializer(serializers.Serializer): notes = serializers.CharField( label=_('Notes'), + max_length=stock_models.StockItemTracking.NOTES_MAX_LENGTH, help_text=_('Optional notes for the stock consumption'), required=False, allow_blank=True, diff --git a/src/backend/InvenTree/build/test_api.py b/src/backend/InvenTree/build/test_api.py index 70fab6608d..eaf7c0b435 100644 --- a/src/backend/InvenTree/build/test_api.py +++ b/src/backend/InvenTree/build/test_api.py @@ -357,6 +357,23 @@ class BuildTest(BuildAPITest): with self.assertRaises(Build.DoesNotExist): Build.objects.get(pk=1) + def test_create_output_batch_code_max_length(self): + """Test that an overly long batch code is rejected with a validation error.""" + url = reverse('api-build-output-create', kwargs={'pk': 1}) + + max_length = int(StockItem._meta.get_field('batch').max_length) + + response = self.post( + url, + {'quantity': 1, 'batch_code': 'x' * (max_length + 1)}, + expected_code=400, + ) + + self.assertIn( + f'Ensure this field has no more than {max_length} characters', + str(response.data['batch_code']), + ) + def test_create_delete_output(self): """Test that we can create and delete build outputs via the API.""" bo = Build.objects.get(pk=1) diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index c40907df49..ec77957283 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -891,6 +891,7 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer): return quantity batch_code = serializers.CharField( + max_length=stock.models.StockItem._meta.get_field('batch').max_length, label=_('Batch Code'), help_text=_('Enter batch code for incoming stock items'), required=False, @@ -917,6 +918,7 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer): status = stock.serializers.StockStatusCustomSerializer(default=StockStatus.OK.value) packaging = serializers.CharField( + max_length=stock.models.StockItem._meta.get_field('packaging').max_length, label=_('Packaging'), help_text=_('Override packaging information for incoming stock items'), required=False, @@ -925,6 +927,7 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer): ) note = serializers.CharField( + max_length=stock.models.StockItemTracking.NOTES_MAX_LENGTH, label=_('Note'), help_text=_('Additional note for incoming stock items'), required=False, @@ -933,6 +936,7 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer): ) barcode = serializers.CharField( + max_length=stock.models.StockItem._meta.get_field('barcode_data').max_length, label=_('Barcode'), help_text=_('Scanned barcode'), default='', @@ -1020,6 +1024,7 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer): ) batch_code = serializers.CharField( + max_length=stock.models.StockItem._meta.get_field('batch').max_length, label=_('Batch Code'), help_text=_( 'Enter batch code for incoming stock items - applied to any line item which does not specify its own batch code' @@ -2339,6 +2344,7 @@ class ReturnOrderReceiveSerializer(serializers.Serializer): ) note = serializers.CharField( + max_length=stock.models.StockItemTracking.NOTES_MAX_LENGTH, label=_('Note'), help_text=_('Additional note for incoming stock items'), required=False, diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index df70d28dc4..c40f35ef94 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -38,7 +38,7 @@ from order.status_codes import ( TransferOrderStatusGroups, ) from part.models import Part -from stock.models import StockItem, StockLocation, StockSortOrder +from stock.models import StockItem, StockItemTracking, StockLocation, StockSortOrder from stock.status_codes import StockHistoryCode, StockStatus from users.models import Owner @@ -1913,6 +1913,48 @@ class PurchaseOrderReceiveTest(OrderTest): self.assertEqual(item_1.batch, 'B-abc-123') self.assertEqual(item_2.batch, 'B-xyz-789') + def test_field_max_length(self): + """Test that overly long values are rejected with a validation error.""" + + def stock_field(name): + return StockItem._meta.get_field(name).max_length + + limits = { + 'batch_code': stock_field('batch'), + 'packaging': stock_field('packaging'), + 'barcode': stock_field('barcode_data'), + 'note': StockItemTracking.NOTES_MAX_LENGTH, + } + + line_data = {'line_item': 1, 'quantity': 10} + + for field, max_length in limits.items(): + line_data[field] = 'x' * (max_length + 1) + + data = { + 'items': [line_data], + 'location': 1, + 'batch_code': 'x' * (limits['batch_code'] + 1), + } + + n = StockItem.objects.count() + + response = self.post(self.url, data, expected_code=400) + + expected = 'Ensure this field has no more than {} characters' + + self.assertIn( + expected.format(limits['batch_code']), str(response.data['batch_code']) + ) + + for field, max_length in limits.items(): + self.assertIn( + expected.format(max_length), str(response.data['items'][0][field]) + ) + + # No stock items should have been created + self.assertEqual(n, StockItem.objects.count()) + def test_top_level_batch_code(self): """Test the top-level 'batch_code' field. @@ -3917,6 +3959,26 @@ class ReturnOrderTests(InvenTreeAPITestCase): ] roles = ['return_order.view'] + def test_receive_note_max_length(self): + """Test that an overly long 'note' value is rejected with a validation error.""" + self.assignRole('return_order.add') + + rma = models.ReturnOrder.objects.first() + url = reverse('api-return-order-receive', kwargs={'pk': rma.pk}) + + max_length = StockItemTracking.NOTES_MAX_LENGTH + + response = self.post( + url, + {'items': [], 'location': 1, 'note': 'x' * (max_length + 1)}, + expected_code=400, + ) + + self.assertIn( + f'Ensure this field has no more than {max_length} characters', + str(response.data['note']), + ) + def test_options(self): """Test the OPTIONS endpoint.""" self.assignRole('return_order.add') diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index b3f7971637..27456d88fe 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -3730,6 +3730,8 @@ class StockItemTracking(InvenTree.models.InvenTreeModel): The tracking data will be removed if the associated part is deleted, as the tracking information is not relevant without the part context. """ + NOTES_MAX_LENGTH = 512 + class Meta: """Meta data for the StockItemTracking class.""" @@ -3781,7 +3783,7 @@ class StockItemTracking(InvenTree.models.InvenTreeModel): notes = models.CharField( blank=True, null=True, - max_length=512, + max_length=NOTES_MAX_LENGTH, verbose_name=_('Notes'), help_text=_('Entry notes'), ) diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index d4f5e9a879..499b3bd713 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -773,6 +773,7 @@ class SerializeStockItemSerializer(serializers.Serializer): notes = serializers.CharField( required=False, + max_length=StockItemTracking.NOTES_MAX_LENGTH, allow_blank=True, label=_('Notes'), help_text=_('Optional note field'), @@ -876,6 +877,7 @@ class InstallStockItemSerializer(serializers.Serializer): ) note = serializers.CharField( + max_length=StockItemTracking.NOTES_MAX_LENGTH, label=_('Note'), help_text=_('Add transaction note (optional)'), required=False, @@ -956,6 +958,7 @@ class UninstallStockItemSerializer(serializers.Serializer): ) note = serializers.CharField( + max_length=StockItemTracking.NOTES_MAX_LENGTH, label=_('Notes'), help_text=_('Add transaction note (optional)'), required=False, @@ -1134,6 +1137,7 @@ class DisassembleStockItemSerializer(serializers.Serializer): notes = serializers.CharField( required=False, + max_length=StockItemTracking.NOTES_MAX_LENGTH, allow_blank=True, label=_('Notes'), help_text=_('Optional note field'), @@ -1301,6 +1305,7 @@ class StockChangeStatusSerializer(serializers.Serializer): ) note = serializers.CharField( + max_length=StockItemTracking.NOTES_MAX_LENGTH, label=_('Notes'), help_text=_('Add transaction note (optional)'), required=False, @@ -1684,6 +1689,7 @@ class StockAssignmentSerializer(serializers.Serializer): notes = serializers.CharField( required=False, + max_length=StockItemTracking.NOTES_MAX_LENGTH, allow_blank=True, label=_('Notes'), help_text=_('Stock assignment notes'), @@ -1773,6 +1779,7 @@ class StockMergeSerializer(serializers.Serializer): notes = serializers.CharField( required=False, + max_length=StockItemTracking.NOTES_MAX_LENGTH, allow_blank=True, label=_('Notes'), help_text=_('Stock merging notes'), @@ -1982,6 +1989,7 @@ class StockAdjustmentSerializer(serializers.Serializer): notes = serializers.CharField( required=False, + max_length=StockItemTracking.NOTES_MAX_LENGTH, allow_blank=True, label=_('Notes'), help_text=_('Stock transaction notes'), diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index b8470284ce..49e2820c9f 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -2108,6 +2108,25 @@ class StockItemTest(StockAPITestCase): self.assertIn('part', response.data) self.assertNotIn('part_detail', response.data) + def test_note_max_length(self): + """Test that overly long transaction notes are rejected with a validation error.""" + max_length = StockItemTracking.NOTES_MAX_LENGTH + note = 'x' * (max_length + 1) + + for url, data in [ + (reverse('api-stock-item-install', kwargs={'pk': 1}), {'stock_item': 2}), + (reverse('api-stock-item-uninstall', kwargs={'pk': 1}), {'location': 1}), + ( + reverse('api-stock-change-status'), + {'items': [1], 'status': StockStatus.DAMAGED.value}, + ), + ]: + response = self.post(url, {**data, 'note': note}, expected_code=400) + self.assertIn( + f'Ensure this field has no more than {max_length} characters', + str(response.data['note']), + ) + def test_install(self): """Test that stock item can be installed into another item, via the API.""" # Select the "parent" stock item @@ -2996,6 +3015,33 @@ class StocktakeTest(StockAPITestCase): self.assertEqual(response.data['items'][0]['pk'], 1234) self.assertEqual(response.data['items'][0]['quantity'], target[endpoint]) + def test_notes_max_length(self): + """Test that overly long notes are rejected with a validation error.""" + max_length = StockItemTracking.NOTES_MAX_LENGTH + + for endpoint in [ + 'api-stock-count', + 'api-stock-add', + 'api-stock-remove', + 'api-stock-transfer', + ]: + url = reverse(endpoint) + data = { + 'items': [{'pk': 1234, 'quantity': 10}], + 'location': 1, + 'notes': 'x' * (max_length + 1), + } + + response = self.post(url, data, expected_code=400) + self.assertIn( + f'Ensure this field has no more than {max_length} characters', + str(response.data['notes']), + ) + + # Notes at the maximum length are accepted + data['notes'] = 'x' * max_length + self.post(url, data, expected_code=201) + def test_count_serialized(self): """Test that counting a serialized stock item correctly updates stocktake_date.""" import datetime