mirror of
https://github.com/inventree/InvenTree.git
synced 2026-10-28 20:33:32 +00:00
[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:
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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user