2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-07-04 06:00:38 +00:00

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 <code@mjmair.com>
Co-authored-by: Oliver Walters <oliver.henry.walters@gmail.com>
This commit is contained in:
John Luetke
2026-06-17 00:59:37 -07:00
committed by GitHub
parent 546958a1cb
commit 83a6729755
4 changed files with 181 additions and 11 deletions
@@ -1,11 +1,14 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 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 - Adds "lookup_field" parameter to the DataImportSessionSerializer, which allows for more flexible lookup of related objects during data import operations
+15 -10
View File
@@ -590,6 +590,21 @@ class Order(
"""Return the Address associated with this order.""" """Return the Address associated with this order."""
return self.address or self.company.primary_address 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 @classmethod
def get_status_class(cls): def get_status_class(cls):
"""Return the enumeration class which represents the 'status' field for this model.""" """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'), 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( supplier = models.ForeignKey(
Company, Company,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@@ -1443,11 +1453,6 @@ class SalesOrder(TotalPriceMixin, Order):
help_text=_('Sales order status'), 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( customer_reference = models.CharField(
max_length=64, max_length=64,
blank=True, blank=True,
@@ -127,6 +127,14 @@ class AbstractOrderSerializer(
# status field cannot be set directly # status field cannot be set directly
status = serializers.IntegerField(read_only=True, label=_('Order Status')) 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 string is *required*
reference = serializers.CharField(required=True) reference = serializers.CharField(required=True)
@@ -199,6 +207,31 @@ class AbstractOrderSerializer(
self.Meta.model.validate_reference_field(reference) self.Meta.model.validate_reference_field(reference)
return 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 @staticmethod
def annotate_queryset(queryset): def annotate_queryset(queryset):
"""Add extra information to the queryset.""" """Add extra information to the queryset."""
+129
View File
@@ -6,6 +6,7 @@ import json
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from typing import Optional from typing import Optional
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import connection from django.db import connection
from django.test.utils import CaptureQueriesContext from django.test.utils import CaptureQueriesContext
@@ -240,6 +241,77 @@ class PurchaseOrderTest(OrderTest):
self.assertEqual(data['pk'], 1) self.assertEqual(data['pk'], 1)
self.assertEqual(data['description'], 'Ordering some screws') 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): def test_po_reference(self):
"""Test that a reference with a too big / small reference is handled correctly.""" """Test that a reference with a too big / small reference is handled correctly."""
# get permissions # get permissions
@@ -1950,6 +2022,63 @@ class SalesOrderTest(OrderTest):
reverse('api-so-detail', kwargs={'pk': 1}), ['customer_detail'] 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): class SalesOrderLineItemTest(OrderTest):
"""Tests for the SalesOrderLineItem API.""" """Tests for the SalesOrderLineItem API."""