diff --git a/CHANGELOG.md b/CHANGELOG.md index f1889def14..4eb6e5f760 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 45540349b1..31b5532ca3 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 5ff4057011..1b97c4bdc1 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -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) diff --git a/src/backend/InvenTree/build/test_api.py b/src/backend/InvenTree/build/test_api.py index e7a305ec53..4388aa527e 100644 --- a/src/backend/InvenTree/build/test_api.py +++ b/src/backend/InvenTree/build/test_api.py @@ -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') diff --git a/src/backend/InvenTree/company/serializers.py b/src/backend/InvenTree/company/serializers.py index 89e3468e6c..57e777e547 100644 --- a/src/backend/InvenTree/company/serializers.py +++ b/src/backend/InvenTree/company/serializers.py @@ -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() diff --git a/src/backend/InvenTree/company/test_api.py b/src/backend/InvenTree/company/test_api.py index 04f8f7fb2c..4b9032bbce 100644 --- a/src/backend/InvenTree/company/test_api.py +++ b/src/backend/InvenTree/company/test_api.py @@ -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') diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index cf57d39da0..c63de82fae 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -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): diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index 61d6cecd77..78d447f431 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -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', diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 32551a2a47..77a7325198 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -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() diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index 68cfdea321..5162feb194 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -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