[API] Mark fields as read-only (#13271)

* [API] Mark fields as read-only

* update tests

* additional ready-only fields
This commit is contained in:
Oliver authored and GitHub committed 2026-11-14 09:55:12 +11:00
1 parent 790112013b
commit 435cdaafbc
10 files changed
+183 -11

No files matched your search

+2
View File
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Breaking Changes
- [#13271](https://github.com/inventree/InvenTree/pull/13271) makes a number of API fields read-only, as they are managed by dedicated endpoints and must not be edited directly: `consumed` (BuildLine), `received_date` (ReturnOrderLineItem), `barcode_hash` (ManufacturerPart, SalesOrderShipment), `issue_date` (SalesOrder, ReturnOrder, TransferOrder) and `complete_date` (ReturnOrder, TransferOrder). Values provided for these fields are now ignored.
### Added
- [#13266](https://github.com/inventree/InvenTree/pull/13266) Adds a "Manufacturer Barcode" plugin, which provides fallback barcode matching against manufacturer part numbers, and a `BARCODE_PRIORITY` attribute which controls the order in which barcode plugins are queried.
@@ -1,11 +1,18 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 578
INVENTREE_API_VERSION = 579
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v579 -> 2026-11-14 : https://github.com/inventree/InvenTree/pull/13271
- The "consumed" field on the BuildLine API is now read-only - stock must be consumed via the build order endpoints
- The "received_date" field on the ReturnOrderLineItem API is now read-only - line items must be received via the return order "receive" endpoint
- The "barcode_hash" field on the ManufacturerPart and SalesOrderShipment APIs is now read-only - barcodes must be assigned via the barcode endpoints
- The "issue_date" field on the SalesOrder, ReturnOrder and TransferOrder APIs is now read-only (as per the PurchaseOrder API) - it is set when the order is issued
- The "complete_date" field on the ReturnOrder and TransferOrder APIs is now read-only (as per the PurchaseOrder API) - it is set when the order is completed
v578 -> 2026-11-13 : https://github.com/inventree/InvenTree/pull/13265
- The "is_building" and "build" fields on the StockItem API are now read-only - build outputs must be created, completed or scrapped via the build order endpoints
+2 -1
View File
@@ -1440,7 +1440,8 @@ class BuildLineSerializer(
)
quantity = serializers.FloatField(label=_('Quantity'))
consumed = serializers.FloatField(label=_('Consumed'))
# Note: The 'consumed' quantity is only adjusted by consuming allocated stock
consumed = serializers.FloatField(label=_('Consumed'), read_only=True)
bom_item = serializers.PrimaryKeyRelatedField(label=_('BOM Item'), read_only=True)
+22
View File
@@ -1874,6 +1874,28 @@ class BuildOutputCancelTest(BuildAPITest):
class BuildLineTests(BuildAPITest):
"""Unit tests for the BuildLine API endpoints."""
def test_consumed_read_only(self):
"""The 'consumed' quantity cannot be changed via the BuildLine API.
Stock must be consumed via the build order endpoints, otherwise
the consumed quantity does not match the stock which was actually consumed.
"""
line = BuildLine.objects.first()
self.assertEqual(line.consumed, 0)
url = reverse('api-build-line-detail', kwargs={'pk': line.pk})
# The 'consumed' value is ignored, other fields are updated
data = self.patch(
url, {'consumed': 5, 'quantity': line.quantity + 1}, expected_code=200
).data
self.assertEqual(data['consumed'], 0)
self.assertEqual(data['quantity'], line.quantity + 1)
line.refresh_from_db()
self.assertEqual(line.consumed, 0)
def test_filter_available(self):
"""Filter BuildLine objects by 'available' status."""
url = reverse('api-build-line-list')
@@ -269,6 +269,9 @@ class ManufacturerPartSerializer(
'parameters',
]
# Note: Barcodes are assigned via the dedicated barcode endpoints
read_only_fields = ['barcode_hash']
tags = common.filters.enable_tags_filter()
parameters = common.filters.enable_parameters_filter()
+18
View File
@@ -549,6 +549,24 @@ class ManufacturerTest(InvenTreeAPITestCase):
roles = ['part.add', 'part.change', 'purchase_order.view']
def test_barcode_hash_read_only(self):
"""The 'barcode_hash' field cannot be changed via the ManufacturerPart API."""
part = ManufacturerPart.objects.first()
self.assertEqual(part.barcode_hash, '')
url = reverse('api-manufacturer-part-detail', kwargs={'pk': part.pk})
# The 'barcode_hash' value is ignored, other fields are updated
data = self.patch(
url, {'barcode_hash': 'abc123', 'description': 'Updated'}, expected_code=200
).data
self.assertEqual(data['barcode_hash'], '')
self.assertEqual(data['description'], 'Updated')
part.refresh_from_db()
self.assertEqual(part.barcode_hash, '')
def test_manufacturer_part_list(self):
"""Test the ManufacturerPart API list functionality."""
url = reverse('api-manufacturer-part-list')
+12 -9
View File
@@ -159,6 +159,8 @@ class AbstractOrderSerializer(
barcode_hash = serializers.CharField(read_only=True)
# Note: The creation date may be specified (e.g. when creating a historical order),
# otherwise it defaults to the current date when the order is created
creation_date = serializers.DateField(
required=False, allow_null=True, label=_('Creation Date')
)
@@ -407,12 +409,7 @@ class PurchaseOrderSerializer(
'delivery_address_detail',
'updated_at',
])
read_only_fields = [
'issue_date',
'complete_date',
'creation_date',
'updated_at',
]
read_only_fields = ['issue_date', 'complete_date', 'updated_at']
extra_kwargs = {
'supplier': {'required': True},
'order_currency': {'required': False},
@@ -1168,7 +1165,7 @@ class SalesOrderSerializer(
'updated_at',
'tags',
])
read_only_fields = ['status', 'creation_date', 'shipment_date', 'updated_at']
read_only_fields = ['status', 'issue_date', 'shipment_date', 'updated_at']
extra_kwargs = {'order_currency': {'required': False}}
def skip_create_fields(self):
@@ -1493,6 +1490,9 @@ class SalesOrderShipmentSerializer(
'tags',
]
# Note: Barcodes are assigned via the dedicated barcode endpoints
read_only_fields = ['barcode_hash']
@staticmethod
def annotate_queryset(queryset):
"""Annotate the queryset with extra information."""
@@ -2209,7 +2209,7 @@ class ReturnOrderSerializer(
'total_price',
'updated_at',
])
read_only_fields = ['creation_date', 'updated_at']
read_only_fields = ['issue_date', 'complete_date', 'updated_at']
def skip_create_fields(self):
"""Skip these fields when instantiating a new object."""
@@ -2416,6 +2416,9 @@ class ReturnOrderLineItemSerializer(
'part_detail',
])
# Note: Line items are marked as received via the 'receive' endpoint
read_only_fields = ['received_date']
order_detail = OptionalField(
serializer_class=ReturnOrderSerializer,
serializer_kwargs={
@@ -2505,7 +2508,7 @@ class TransferOrderSerializer(
'consume',
'complete_date',
])
read_only_fields = ['creation_date']
read_only_fields = ['issue_date', 'complete_date']
extra_kwargs = {}
def skip_create_fields(self):
+110
View File
@@ -3693,6 +3693,25 @@ class SalesOrderAllocateTest(OrderTest):
self.assertEqual(self.shipment.delivery_date, datetime(2023, 12, 5).date())
self.assertTrue(self.shipment.is_delivered())
def test_shipment_barcode_hash_read_only(self):
"""The 'barcode_hash' field cannot be changed via the SalesOrderShipment API."""
url = reverse('api-so-shipment-detail', kwargs={'pk': self.shipment.pk})
self.assertEqual(self.shipment.barcode_hash, '')
# The 'barcode_hash' value is ignored, other fields are updated
data = self.patch(
url,
{'barcode_hash': 'abc123', 'tracking_number': 'TRK999'},
expected_code=200,
).data
self.assertEqual(data['barcode_hash'], '')
self.assertEqual(data['tracking_number'], 'TRK999')
self.shipment.refresh_from_db()
self.assertEqual(self.shipment.barcode_hash, '')
def test_shipment_delivery_date(self):
"""Test delivery date functions via API."""
url = reverse('api-so-shipment-detail', kwargs={'pk': self.shipment.pk})
@@ -4620,9 +4639,100 @@ class ReturnOrderTests(InvenTreeAPITestCase):
)
class OrderDateReadOnlyTests(InvenTreeAPITestCase):
"""The 'issue_date' and 'complete_date' fields are set by issuing / completing an order."""
fixtures = [
'category',
'part',
'company',
'location',
'supplier_part',
'stock',
'order',
'sales_order',
'return_order',
'transfer_order',
]
superuser = True
def test_dates_read_only(self):
"""The 'issue_date' and 'complete_date' fields cannot be changed via the order API."""
orders = [
(models.PurchaseOrder, 'api-po-detail', ['issue_date', 'complete_date']),
(models.SalesOrder, 'api-so-detail', ['issue_date']),
(
models.ReturnOrder,
'api-return-order-detail',
['issue_date', 'complete_date'],
),
(
models.TransferOrder,
'api-transfer-order-detail',
['issue_date', 'complete_date'],
),
]
for model, url_name, fields in orders:
with self.subTest(model=model.__name__):
# Select a "pending" order, which can be freely edited
order = model.objects.filter(status=10).first()
self.assertIsNotNone(order)
# Start from a known state, without going via the API
# (some fixture orders do not have a valid reference)
model.objects.filter(pk=order.pk).update(
reference=model.generate_reference(), **dict.fromkeys(fields)
)
url = reverse(url_name, kwargs={'pk': order.pk})
# The date values are ignored, other fields are updated
data = self.patch(
url,
{**dict.fromkeys(fields, '2024-01-01'), 'description': 'Updated'},
expected_code=200,
).data
self.assertEqual(data['description'], 'Updated')
order.refresh_from_db()
for field in fields:
self.assertIsNone(data[field])
self.assertIsNone(getattr(order, field))
class ReturnOrderLineItemTests(InvenTreeAPITestCase):
"""Unit tests for ReturnOrderLineItem API endpoints."""
def test_received_date_read_only(self):
"""The 'received_date' field cannot be changed via the ReturnOrderLineItem API.
Line items must be received via the return order 'receive' endpoint,
otherwise the returned stock item is never actually received into stock.
"""
self.assignRole('return_order.change')
line = models.ReturnOrderLineItem.objects.filter(received_date=None).first()
self.assertIsNotNone(line)
url = reverse('api-return-order-line-detail', kwargs={'pk': line.pk})
# The 'received_date' value is ignored, other fields are updated
data = self.patch(
url,
{'received_date': '2024-01-01', 'reference': 'Updated'},
expected_code=200,
).data
self.assertIsNone(data['received_date'])
self.assertEqual(data['reference'], 'Updated')
line.refresh_from_db()
self.assertIsNone(line.received_date)
fixtures = [
'category',
'company',
@@ -196,6 +196,8 @@ class CategoryTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
'subcategories',
]
read_only_fields = ['pathstring']
subcategories = serializers.IntegerField(label=_('Subcategories'), read_only=True)
icon = serializers.CharField(
@@ -480,6 +482,7 @@ class DefaultLocationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
model = stock_models.StockLocation
fields = ['pk', 'name', 'pathstring']
read_only_fields = ['pathstring']
@register_importer()
@@ -190,6 +190,7 @@ class LocationBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
model = StockLocation
fields = ['pk', 'name', 'pathstring']
read_only_fields = ['pathstring']
@register_importer()
@@ -1405,6 +1406,8 @@ class LocationTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
'sublocations',
]
read_only_fields = ['pathstring']
sublocations = serializers.IntegerField(label=_('Sublocations'), read_only=True)
@staticmethod