2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-07-04 14:10:52 +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
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
+15 -10
View File
@@ -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,
@@ -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."""
+129
View File
@@ -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."""