Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/backend/InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
"""InvenTree API version information."""

# InvenTree API version
INVENTREE_API_VERSION = 497
INVENTREE_API_VERSION = 498
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""

INVENTREE_API_TEXT = """

v498 -> 2026-05-30 : https://github.com/inventree/InvenTree/pull/11982
- An order's "status_custom_key" can be updated via PATCH API endpoint

v497 -> 2026-05-27 : https://github.com/inventree/InvenTree/pull/12019
- Adds "location" field to StockCount API endpoint

Expand Down
25 changes: 15 additions & 10 deletions src/backend/InvenTree/order/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,21 @@ def order_address(self):
"""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(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is going to result in a N + 1 query problem - each retrieved item will have multiple database hits.

In fact, this problem already exists in the codebase. I'm currently working on a patch for this which I will amend to your branch

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."""
Expand Down Expand Up @@ -691,11 +706,6 @@ def __str__(self):
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,
Expand Down Expand Up @@ -1431,11 +1441,6 @@ def company(self):
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,
Expand Down
35 changes: 34 additions & 1 deletion src/backend/InvenTree/order/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,19 @@
)

# Human-readable status text (read-only)
status_text = serializers.CharField(source='get_status_display', read_only=True)
status_text = serializers.CharField(read_only=True)

# 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'),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this set we loose dynamic choice enumeration in the schema description; @SchrodingersGat is that acceptable or should we patch the schema generation mechanism

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been trying to familiarize myself with how this works, but don't quite understand how it broke. It seems like the schema now thinks that the status and custom_status_key fields are more tightly coupled now?

If you can point me in the right direction, I can see if my solution can be implemented in a way that doesn't change this.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@matmair what would you propose as an alternative?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleting the description or patching our custom schema generator to still add the choices if a description is set on a serializer.

allow_null=True,
default=None,
)

# Reference string is *required*
reference = serializers.CharField(required=True)

Expand Down Expand Up @@ -194,6 +202,31 @@
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:

Check failure on line 222 in src/backend/InvenTree/order/serializers.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Specify an exception class to catch or reraise the exception

See more on https://sonarcloud.io/project/issues?id=inventree_InvenTree&issues=AZ5MevuHM_FZS9T6lzQ3&open=AZ5MevuHM_FZS9T6lzQ3&pullRequest=11982
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."""
Expand Down
74 changes: 73 additions & 1 deletion src/backend/InvenTree/order/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
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
Expand All @@ -16,7 +17,7 @@
from rest_framework import status

from common.currency import currency_codes
from common.models import InvenTreeSetting
from common.models import InvenTreeCustomUserStateModel, InvenTreeSetting
from common.settings import set_global_setting
from company.models import Company, SupplierPart, SupplierPriceBreak
from InvenTree.unit_test import InvenTreeAPITestCase
Expand Down Expand Up @@ -240,6 +241,77 @@ def test_po_detail(self):
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
Expand Down
Loading