[bug] Handle length limitation for "notes" fields (#13023)

* [bug] Handle length limitation for "notes" fields

* Bump API version

* Fix typing

* Further typing fixes
This commit is contained in:
Oliver authored and GitHub committed 2026-10-17 11:26:57 +11:00
1 parent 8d96db0040
commit a5895adcac
10 files changed
+177 -5

No files matched your search

@@ -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
+10 -1
View File
@@ -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)
+10
View File
@@ -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)
+9 -1
View File
@@ -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,
+17
View File
@@ -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)
@@ -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,
+63 -1
View File
@@ -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')
+3 -1
View File
@@ -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'),
)
@@ -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'),
+46
View File
@@ -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