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:
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user