From 83a672975533fdd10efa3fce9801587f7d309643 Mon Sep 17 00:00:00 2001 From: John Luetke Date: Wed, 17 Jun 2026 00:59:37 -0700 Subject: [PATCH] Change order custom status via api (#11982) * Set custom_status_key via API Refactor `custom_status_key` to be writable via the API and validate that the proposed value is valid for the current order status * Refactor status_text serializer to consider custom status label * Update api_version.py * Additional unit testagainst N + 1 --------- Co-authored-by: Matthias Mair Co-authored-by: Oliver Walters --- .../InvenTree/InvenTree/api_version.py | 5 +- src/backend/InvenTree/order/models.py | 25 ++-- src/backend/InvenTree/order/serializers.py | 33 +++++ src/backend/InvenTree/order/test_api.py | 129 ++++++++++++++++++ 4 files changed, 181 insertions(+), 11 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 8c33402798..8637e9232d 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 507 +INVENTREE_API_VERSION = 508 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v508 -> 2026-06-17 : https://github.com/inventree/InvenTree/pull/11982 + - An order's "status_custom_key" can be updated via PATCH API endpoint + v507 -> 2026-06-16 : https://github.com/inventree/InvenTree/pull/12180 - Adds "lookup_field" parameter to the DataImportSessionSerializer, which allows for more flexible lookup of related objects during data import operations diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 428f9f1d29..17f9d8193f 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -590,6 +590,21 @@ class Order( """Return the Address associated with this order.""" return self.address or self.company.primary_address + @property + def status_text(self): + """Return the text representation of the current status. This will consider any custom status.""" + if self.get_custom_status() is not None: + from generic.states.custom import ( + get_logical_value as get_custom_state_logical_value, + ) + + custom_status = get_custom_state_logical_value( + self.get_custom_status(), model=self._meta.model_name + ) + return custom_status.label + else: + return self.status_class.label(self.get_status()) + @classmethod def get_status_class(cls): """Return the enumeration class which represents the 'status' field for this model.""" @@ -692,11 +707,6 @@ class PurchaseOrder(TotalPriceMixin, Order): help_text=_('Purchase order status'), ) - @property - def status_text(self): - """Return the text representation of the status field.""" - return PurchaseOrderStatus.text(self.status) - supplier = models.ForeignKey( Company, on_delete=models.SET_NULL, @@ -1443,11 +1453,6 @@ class SalesOrder(TotalPriceMixin, Order): help_text=_('Sales order status'), ) - @property - def status_text(self) -> str: - """Return the text representation of the status field.""" - return SalesOrderStatus.text(self.status) - customer_reference = models.CharField( max_length=64, blank=True, diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index 108a6d51c1..7ef36f5609 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -127,6 +127,14 @@ class AbstractOrderSerializer( # status field cannot be set directly status = serializers.IntegerField(read_only=True, label=_('Order Status')) + # can be set directly, but must be valid for the current order status + status_custom_key = serializers.IntegerField( + label=_('Custom Status Key'), + help_text=_('Update order status to a custom value for this logical value'), + allow_null=True, + default=None, + ) + # Reference string is *required* reference = serializers.CharField(required=True) @@ -199,6 +207,31 @@ class AbstractOrderSerializer( self.Meta.model.validate_reference_field(reference) return reference + def validate_status_custom_key(self, value): + """Validate the status_custom_key field. + + Ensure the custom status key is valid for the logical order status. + """ + if value is None: + return value + + from generic.states.custom import get_logical_value + + if not isinstance(value, int): + raise ValidationError(_('Custom status key must be an integer')) + + try: + custom_status = get_logical_value( + value, model=self.Meta.model._meta.model_name + ) + except: + raise ValidationError(_('Invalid custom status key')) + + if custom_status.logical_key is not self.instance.status: + raise ValidationError(_('Invalid custom status key for this order status')) + + return value + @staticmethod def annotate_queryset(queryset): """Add extra information to the queryset.""" diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index 4e4347a92e..10b944f40f 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -6,6 +6,7 @@ import json from datetime import date, datetime, timedelta from typing import Optional +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import connection from django.test.utils import CaptureQueriesContext @@ -240,6 +241,77 @@ class PurchaseOrderTest(OrderTest): self.assertEqual(data['pk'], 1) self.assertEqual(data['description'], 'Ordering some screws') + def test_po_status_custom_key_options(self): + """Test that status_custom_key is exposed as writable in options.""" + self.assignRole('purchase_order.add') + + response = self.options(self.LIST_URL, expected_code=200) + post = response.data['actions']['POST'] + + self.assertIn('status_custom_key', post) + self.assertEqual(post['status_custom_key']['required'], False) + self.assertEqual(post['status_custom_key']['read_only'], False) + + def test_po_status_custom_key_patch_valid(self): + """Test patching a valid custom status key for the current PO status.""" + self.assignRole('purchase_order.change') + + po = models.PurchaseOrder.objects.get(pk=1) + self.assertEqual(po.status, PurchaseOrderStatus.PENDING.value) + + custom_status = InvenTreeCustomUserStateModel.objects.create( + key=901, + name='PO Pending Custom', + label='PO Pending Custom', + color='secondary', + logical_key=PurchaseOrderStatus.PENDING.value, + model=ContentType.objects.get_for_model(models.PurchaseOrder), + reference_status='PurchaseOrderStatus', + ) + + url = reverse('api-po-detail', kwargs={'pk': po.pk}) + response = self.patch( + url, {'status_custom_key': custom_status.key}, expected_code=200 + ) + + self.assertEqual(response.data['status'], PurchaseOrderStatus.PENDING.value) + self.assertEqual(response.data['status_custom_key'], custom_status.key) + + def test_po_status_custom_key_patch_invalid(self): + """Test patching an invalid custom status key for a PO.""" + self.assignRole('purchase_order.change') + + po = models.PurchaseOrder.objects.get(pk=1) + url = reverse('api-po-detail', kwargs={'pk': po.pk}) + + response = self.patch(url, {'status_custom_key': 999999}, expected_code=400) + + self.assertIn('status_custom_key', response.data) + + def test_po_status_custom_key_patch_wrong_logical_status(self): + """Test patching a custom key mapped to a different logical status.""" + self.assignRole('purchase_order.change') + + po = models.PurchaseOrder.objects.get(pk=1) + self.assertEqual(po.status, PurchaseOrderStatus.PENDING.value) + + custom_status = InvenTreeCustomUserStateModel.objects.create( + key=902, + name='PO Placed Custom', + label='PO Placed Custom', + color='secondary', + logical_key=PurchaseOrderStatus.PLACED.value, + model=ContentType.objects.get_for_model(models.PurchaseOrder), + reference_status='PurchaseOrderStatus', + ) + + url = reverse('api-po-detail', kwargs={'pk': po.pk}) + response = self.patch( + url, {'status_custom_key': custom_status.key}, expected_code=400 + ) + + self.assertIn('status_custom_key', response.data) + def test_po_reference(self): """Test that a reference with a too big / small reference is handled correctly.""" # get permissions @@ -1950,6 +2022,63 @@ class SalesOrderTest(OrderTest): reverse('api-so-detail', kwargs={'pk': 1}), ['customer_detail'] ) + def test_so_custom_status_query_count(self): + """Test that listing SalesOrders with custom statuses does not cause N+1 queries. + + Ensures that resolving the 'status_text' field for custom status values + is O(1) in database queries, not O(N) relative to the number of results. + """ + so_content_type = ContentType.objects.get_for_model(models.SalesOrder) + + logical_keys = [ + SalesOrderStatus.PENDING.value, + SalesOrderStatus.IN_PROGRESS.value, + SalesOrderStatus.SHIPPED.value, + SalesOrderStatus.ON_HOLD.value, + SalesOrderStatus.COMPLETE.value, + SalesOrderStatus.CANCELLED.value, + SalesOrderStatus.PENDING.value, + SalesOrderStatus.IN_PROGRESS.value, + SalesOrderStatus.SHIPPED.value, + SalesOrderStatus.ON_HOLD.value, + ] + + custom_statuses = [ + InvenTreeCustomUserStateModel.objects.create( + key=2000 + i, + name=f'SoCustomStatus{i}', + label=f'SO Custom Status Label {i}', + color='secondary', + logical_key=logical_keys[i], + model=so_content_type, + reference_status='SalesOrderStatus', + ) + for i in range(10) + ] + + customer = Company.objects.filter(is_customer=True).first() + models.SalesOrder.objects.bulk_create([ + models.SalesOrder( + customer=customer, + reference=f'SO-QTEST-{i}', + status=custom_statuses[i % 10].logical_key, + status_custom_key=custom_statuses[i % 10].key, + ) + for i in range(100) + ]) + + for limit in [1, 5, 10, 25, 50, 100]: + response = self.get( + self.LIST_URL, + data={'limit': limit}, + expected_code=200, + max_query_count=50, + ) + + for result in response.data['results']: + self.assertIn('status_text', result) + self.assertIsNotNone(result['status_text']) + class SalesOrderLineItemTest(OrderTest): """Tests for the SalesOrderLineItem API."""