mirror of
https://github.com/inventree/InvenTree.git
synced 2026-06-06 08:54:24 +00:00
[API] Fix 1 + N query for custom status fields (#12055)
* Add CustomStatusSerializerMixin - Caches custom status values * Add unit test for PurchaseOrder model * Implement changes for StockItemSerializer * Implement changes for BuildOrder * Fix unit test * Bump API version * Add serializer field type hint
This commit is contained in:
@@ -1,11 +1,14 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""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 = """
|
||||||
|
|
||||||
|
v498 -> 2026-05-31 : https://github.com/inventree/InvenTree/pull/12055
|
||||||
|
- Updates the "status_text" field for models which support custom status values
|
||||||
|
|
||||||
v497 -> 2026-05-27 : https://github.com/inventree/InvenTree/pull/12019
|
v497 -> 2026-05-27 : https://github.com/inventree/InvenTree/pull/12019
|
||||||
- Adds "location" field to StockCount API endpoint
|
- Adds "location" field to StockCount API endpoint
|
||||||
|
|
||||||
|
|||||||
@@ -764,6 +764,56 @@ class InvenTreeDecimalField(serializers.FloatField):
|
|||||||
raise serializers.ValidationError(_('Invalid value'))
|
raise serializers.ValidationError(_('Invalid value'))
|
||||||
|
|
||||||
|
|
||||||
|
class CustomStatusSerializerMixin(serializers.Serializer):
|
||||||
|
"""Serializer mixin for models that support custom status values.
|
||||||
|
|
||||||
|
Provides a `status_text` SerializerMethodField that resolves custom
|
||||||
|
status labels with a single database query per model per serializer
|
||||||
|
context (i.e. one query for a whole list page) rather than one query per
|
||||||
|
object (N+1).
|
||||||
|
"""
|
||||||
|
|
||||||
|
status_text = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||||
|
def get_status_text(self, instance) -> Optional[str]:
|
||||||
|
"""Return the human-readable status text for the instance.
|
||||||
|
|
||||||
|
Uses a per-context cache keyed by model name so that all objects in a
|
||||||
|
single serialization pass share one DB hit for custom label lookup.
|
||||||
|
|
||||||
|
During write operations DRF may call to_representation on the raw
|
||||||
|
validated_data dict rather than a model instance (e.g. when building
|
||||||
|
response headers). Return None in that case — the response body is
|
||||||
|
always produced from a real instance via a separate serializer call.
|
||||||
|
"""
|
||||||
|
if not hasattr(instance, 'get_custom_status'):
|
||||||
|
return None
|
||||||
|
|
||||||
|
custom_key = instance.get_custom_status()
|
||||||
|
|
||||||
|
if custom_key is None:
|
||||||
|
return instance.status_class.label(instance.get_status())
|
||||||
|
|
||||||
|
model_name = instance._meta.model_name
|
||||||
|
cache_key = f'_custom_status_labels_{model_name}'
|
||||||
|
|
||||||
|
# Cache a dict of custom status labels for this model, if not already cached
|
||||||
|
if cache_key not in self.context:
|
||||||
|
from common.models import InvenTreeCustomUserStateModel
|
||||||
|
|
||||||
|
self.context[cache_key] = {
|
||||||
|
obj.key: obj.label
|
||||||
|
for obj in InvenTreeCustomUserStateModel.objects.filter(
|
||||||
|
model__model=model_name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return self.context[cache_key].get(
|
||||||
|
custom_key, instance.status_class.label(instance.get_status())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class NotesFieldMixin:
|
class NotesFieldMixin:
|
||||||
"""Serializer mixin for handling 'notes' fields.
|
"""Serializer mixin for handling 'notes' fields.
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from common.settings import get_global_setting
|
|||||||
from generic.states.fields import InvenTreeCustomStatusSerializerMixin
|
from generic.states.fields import InvenTreeCustomStatusSerializerMixin
|
||||||
from InvenTree.mixins import DataImportExportSerializerMixin
|
from InvenTree.mixins import DataImportExportSerializerMixin
|
||||||
from InvenTree.serializers import (
|
from InvenTree.serializers import (
|
||||||
|
CustomStatusSerializerMixin,
|
||||||
FilterableSerializerMixin,
|
FilterableSerializerMixin,
|
||||||
InvenTreeDecimalField,
|
InvenTreeDecimalField,
|
||||||
InvenTreeModelSerializer,
|
InvenTreeModelSerializer,
|
||||||
@@ -53,6 +54,7 @@ from .status_codes import BuildStatus
|
|||||||
|
|
||||||
|
|
||||||
class BuildSerializer(
|
class BuildSerializer(
|
||||||
|
CustomStatusSerializerMixin,
|
||||||
FilterableSerializerMixin,
|
FilterableSerializerMixin,
|
||||||
NotesFieldMixin,
|
NotesFieldMixin,
|
||||||
DataImportExportSerializerMixin,
|
DataImportExportSerializerMixin,
|
||||||
@@ -116,8 +118,6 @@ class BuildSerializer(
|
|||||||
|
|
||||||
level = serializers.IntegerField(label=_('Build Level'), read_only=True)
|
level = serializers.IntegerField(label=_('Build Level'), read_only=True)
|
||||||
|
|
||||||
status_text = serializers.CharField(source='get_status_display', read_only=True)
|
|
||||||
|
|
||||||
part_detail = OptionalField(
|
part_detail = OptionalField(
|
||||||
serializer_class=part_serializers.PartBriefSerializer,
|
serializer_class=part_serializers.PartBriefSerializer,
|
||||||
serializer_kwargs={'source': 'part', 'many': False, 'read_only': True},
|
serializer_kwargs={'source': 'part', 'many': False, 'read_only': True},
|
||||||
|
|||||||
@@ -1817,6 +1817,97 @@ class BuildConsumeTest(BuildAPITest):
|
|||||||
self.assertEqual(line.consumed, 100)
|
self.assertEqual(line.consumed, 100)
|
||||||
|
|
||||||
|
|
||||||
|
class BuildCustomStatusTest(BuildAPITest):
|
||||||
|
"""Tests for custom status values on Build orders."""
|
||||||
|
|
||||||
|
url = reverse('api-build-list')
|
||||||
|
|
||||||
|
def test_custom_status_query_count(self):
|
||||||
|
"""Test that listing Build orders with custom statuses does not cause N+1 queries.
|
||||||
|
|
||||||
|
Ensures that resolving 'status_text' for custom status values is O(1)
|
||||||
|
in database queries, not O(N) relative to the number of results.
|
||||||
|
"""
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
|
||||||
|
from common.models import InvenTreeCustomUserStateModel
|
||||||
|
|
||||||
|
build_content_type = ContentType.objects.get_for_model(Build)
|
||||||
|
|
||||||
|
# 10 custom status values - different keys, labels, and logical_keys
|
||||||
|
logical_keys = [
|
||||||
|
BuildStatus.PENDING.value,
|
||||||
|
BuildStatus.PRODUCTION.value,
|
||||||
|
BuildStatus.ON_HOLD.value,
|
||||||
|
BuildStatus.CANCELLED.value,
|
||||||
|
BuildStatus.COMPLETE.value,
|
||||||
|
BuildStatus.PENDING.value,
|
||||||
|
BuildStatus.PRODUCTION.value,
|
||||||
|
BuildStatus.ON_HOLD.value,
|
||||||
|
BuildStatus.CANCELLED.value,
|
||||||
|
BuildStatus.COMPLETE.value,
|
||||||
|
]
|
||||||
|
|
||||||
|
custom_statuses = [
|
||||||
|
InvenTreeCustomUserStateModel.objects.create(
|
||||||
|
key=3000 + i,
|
||||||
|
name=f'BuildCustomStatus{i}',
|
||||||
|
label=f'Build Custom Status Label {i}',
|
||||||
|
color='secondary',
|
||||||
|
logical_key=logical_keys[i],
|
||||||
|
model=build_content_type,
|
||||||
|
reference_status='BuildStatus',
|
||||||
|
)
|
||||||
|
for i in range(10)
|
||||||
|
]
|
||||||
|
|
||||||
|
part = Part.objects.filter(assembly=True).first()
|
||||||
|
|
||||||
|
# Build is an MPTT tree model; bulk_create requires tree fields to be
|
||||||
|
# populated manually. All new orders are root nodes (no parent) so each
|
||||||
|
# gets its own unique tree_id.
|
||||||
|
from django.db.models import Max
|
||||||
|
|
||||||
|
next_tree_id = (Build.objects.aggregate(m=Max('tree_id'))['m'] or 0) + 1
|
||||||
|
|
||||||
|
# Create 100 build orders, cycling through the 10 custom statuses
|
||||||
|
Build.objects.bulk_create([
|
||||||
|
Build(
|
||||||
|
part=part,
|
||||||
|
reference=f'BO-QTEST-{i}',
|
||||||
|
quantity=1,
|
||||||
|
status=custom_statuses[i % 10].logical_key,
|
||||||
|
status_custom_key=custom_statuses[i % 10].key,
|
||||||
|
lft=1,
|
||||||
|
rght=2,
|
||||||
|
level=0,
|
||||||
|
tree_id=next_tree_id + i,
|
||||||
|
)
|
||||||
|
for i in range(100)
|
||||||
|
])
|
||||||
|
|
||||||
|
# Lookup: custom_key -> custom_status_object, for quick per-row assertions
|
||||||
|
custom_lookup = {cs.key: cs for cs in custom_statuses}
|
||||||
|
|
||||||
|
# Query count must stay below the fixed threshold regardless of limit.
|
||||||
|
# An N+1 bug would push limit=50 or limit=100 well over the threshold.
|
||||||
|
for limit in [1, 10, 50, 100]:
|
||||||
|
response = self.get(
|
||||||
|
self.url, data={'limit': limit}, expected_code=200, max_query_count=50
|
||||||
|
)
|
||||||
|
|
||||||
|
for result in response.data['results']:
|
||||||
|
cs = custom_lookup.get(result['status_custom_key'])
|
||||||
|
|
||||||
|
if cs is None:
|
||||||
|
# Build from fixtures - no custom status assigned
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.assertEqual(result['status'], cs.logical_key)
|
||||||
|
self.assertEqual(result['status_custom_key'], cs.key)
|
||||||
|
self.assertEqual(result['status_text'], cs.label)
|
||||||
|
|
||||||
|
|
||||||
class BuildAutoAllocateAPITest(InvenTreeAPITestCase):
|
class BuildAutoAllocateAPITest(InvenTreeAPITestCase):
|
||||||
"""API integration tests for BuildAutoAllocate endpoint back-ports (stock_sort_by, build_lines)."""
|
"""API integration tests for BuildAutoAllocate endpoint back-ports (stock_sort_by, build_lines)."""
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from importer.registry import register_importer
|
|||||||
from InvenTree.helpers import extract_serial_numbers, hash_barcode, normalize, str2bool
|
from InvenTree.helpers import extract_serial_numbers, hash_barcode, normalize, str2bool
|
||||||
from InvenTree.mixins import DataImportExportSerializerMixin
|
from InvenTree.mixins import DataImportExportSerializerMixin
|
||||||
from InvenTree.serializers import (
|
from InvenTree.serializers import (
|
||||||
|
CustomStatusSerializerMixin,
|
||||||
FilterableSerializerMixin,
|
FilterableSerializerMixin,
|
||||||
InvenTreeCurrencySerializer,
|
InvenTreeCurrencySerializer,
|
||||||
InvenTreeDecimalField,
|
InvenTreeDecimalField,
|
||||||
@@ -100,7 +101,10 @@ class DuplicateOrderSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class AbstractOrderSerializer(
|
class AbstractOrderSerializer(
|
||||||
DataImportExportSerializerMixin, FilterableSerializerMixin, serializers.Serializer
|
CustomStatusSerializerMixin,
|
||||||
|
DataImportExportSerializerMixin,
|
||||||
|
FilterableSerializerMixin,
|
||||||
|
serializers.Serializer,
|
||||||
):
|
):
|
||||||
"""Abstract serializer class which provides fields common to all order types."""
|
"""Abstract serializer class which provides fields common to all order types."""
|
||||||
|
|
||||||
@@ -118,9 +122,6 @@ class AbstractOrderSerializer(
|
|||||||
read_only=True, allow_null=True, label=_('Completed Lines')
|
read_only=True, allow_null=True, label=_('Completed Lines')
|
||||||
)
|
)
|
||||||
|
|
||||||
# Human-readable status text (read-only)
|
|
||||||
status_text = serializers.CharField(source='get_status_display', read_only=True)
|
|
||||||
|
|
||||||
# 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'))
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from icalendar import Calendar
|
|||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
from common.currency import currency_codes
|
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 common.settings import set_global_setting
|
||||||
from company.models import Company, SupplierPart, SupplierPriceBreak
|
from company.models import Company, SupplierPart, SupplierPriceBreak
|
||||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||||
@@ -737,6 +737,69 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_po_custom_status_query_count(self):
|
||||||
|
"""Test that listing PurchaseOrders 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.
|
||||||
|
"""
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
|
||||||
|
po_content_type = ContentType.objects.get_for_model(models.PurchaseOrder)
|
||||||
|
|
||||||
|
# 10 custom status values - different keys, labels, and logical_keys
|
||||||
|
logical_keys = [
|
||||||
|
PurchaseOrderStatus.PENDING.value,
|
||||||
|
PurchaseOrderStatus.PLACED.value,
|
||||||
|
PurchaseOrderStatus.ON_HOLD.value,
|
||||||
|
PurchaseOrderStatus.COMPLETE.value,
|
||||||
|
PurchaseOrderStatus.CANCELLED.value,
|
||||||
|
PurchaseOrderStatus.LOST.value,
|
||||||
|
PurchaseOrderStatus.RETURNED.value,
|
||||||
|
PurchaseOrderStatus.PENDING.value,
|
||||||
|
PurchaseOrderStatus.PLACED.value,
|
||||||
|
PurchaseOrderStatus.ON_HOLD.value,
|
||||||
|
]
|
||||||
|
|
||||||
|
custom_statuses = [
|
||||||
|
InvenTreeCustomUserStateModel.objects.create(
|
||||||
|
key=1000 + i,
|
||||||
|
name=f'PoCustomStatus{i}',
|
||||||
|
label=f'PO Custom Status Label {i}',
|
||||||
|
color='secondary',
|
||||||
|
logical_key=logical_keys[i],
|
||||||
|
model=po_content_type,
|
||||||
|
reference_status='PurchaseOrderStatus',
|
||||||
|
)
|
||||||
|
for i in range(10)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Create 100 purchase orders, cycling through the custom statuses
|
||||||
|
supplier = Company.objects.filter(is_supplier=True).first()
|
||||||
|
models.PurchaseOrder.objects.bulk_create([
|
||||||
|
models.PurchaseOrder(
|
||||||
|
supplier=supplier,
|
||||||
|
reference=f'PO-QTEST-{i}',
|
||||||
|
status=custom_statuses[i % 10].logical_key,
|
||||||
|
status_custom_key=custom_statuses[i % 10].key,
|
||||||
|
)
|
||||||
|
for i in range(100)
|
||||||
|
])
|
||||||
|
|
||||||
|
# Query count must stay below the fixed threshold for all limit values.
|
||||||
|
# An N+1 bug would push limit=50 or limit=100 well over the threshold.
|
||||||
|
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 PurchaseOrderLineItemTest(OrderTest):
|
class PurchaseOrderLineItemTest(OrderTest):
|
||||||
"""Unit tests for PurchaseOrderLineItems."""
|
"""Unit tests for PurchaseOrderLineItems."""
|
||||||
@@ -771,7 +834,7 @@ class PurchaseOrderLineItemTest(OrderTest):
|
|||||||
# Try to delete a set of line items via their IDs
|
# Try to delete a set of line items via their IDs
|
||||||
self.delete(url, {'items': [1, 2]}, expected_code=200)
|
self.delete(url, {'items': [1, 2]}, expected_code=200)
|
||||||
|
|
||||||
# We should have 2 less PurchaseOrderLineItems after deletign them
|
# We should have 2 less PurchaseOrderLineItems after deleting them
|
||||||
self.assertEqual(models.PurchaseOrderLineItem.objects.count(), n - 2)
|
self.assertEqual(models.PurchaseOrderLineItem.objects.count(), n - 2)
|
||||||
|
|
||||||
def test_po_line_merge_pricing(self):
|
def test_po_line_merge_pricing(self):
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from generic.states.fields import InvenTreeCustomStatusSerializerMixin
|
|||||||
from importer.registry import register_importer
|
from importer.registry import register_importer
|
||||||
from InvenTree.mixins import DataImportExportSerializerMixin
|
from InvenTree.mixins import DataImportExportSerializerMixin
|
||||||
from InvenTree.serializers import (
|
from InvenTree.serializers import (
|
||||||
|
CustomStatusSerializerMixin,
|
||||||
InvenTreeCurrencySerializer,
|
InvenTreeCurrencySerializer,
|
||||||
InvenTreeDecimalField,
|
InvenTreeDecimalField,
|
||||||
OptionalField,
|
OptionalField,
|
||||||
@@ -308,6 +309,7 @@ class StockItemTestResultSerializer(
|
|||||||
|
|
||||||
@register_importer()
|
@register_importer()
|
||||||
class StockItemSerializer(
|
class StockItemSerializer(
|
||||||
|
CustomStatusSerializerMixin,
|
||||||
InvenTree.serializers.FilterableSerializerMixin,
|
InvenTree.serializers.FilterableSerializerMixin,
|
||||||
DataImportExportSerializerMixin,
|
DataImportExportSerializerMixin,
|
||||||
InvenTreeCustomStatusSerializerMixin,
|
InvenTreeCustomStatusSerializerMixin,
|
||||||
@@ -565,10 +567,6 @@ class StockItemSerializer(
|
|||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
status_text = serializers.CharField(
|
|
||||||
source='get_status_display', read_only=True, label=_('Status')
|
|
||||||
)
|
|
||||||
|
|
||||||
SKU = serializers.CharField(
|
SKU = serializers.CharField(
|
||||||
source='supplier_part.SKU',
|
source='supplier_part.SKU',
|
||||||
read_only=True,
|
read_only=True,
|
||||||
|
|||||||
@@ -1411,6 +1411,82 @@ class CustomStockItemStatusTest(StockAPITestCase):
|
|||||||
self.assertEqual(status['value'], self.status.key)
|
self.assertEqual(status['value'], self.status.key)
|
||||||
self.assertEqual(status['display_name'], self.status.label)
|
self.assertEqual(status['display_name'], self.status.label)
|
||||||
|
|
||||||
|
def test_custom_status_query_count(self):
|
||||||
|
"""Test that listing StockItems with custom statuses does not cause N+1 queries.
|
||||||
|
|
||||||
|
Ensures that resolving 'status_text' for custom status values is O(1)
|
||||||
|
in database queries, not O(N) relative to the number of results.
|
||||||
|
"""
|
||||||
|
stock_content_type = ContentType.objects.get(model='stockitem')
|
||||||
|
|
||||||
|
# 10 custom status values - different keys, labels, and logical_keys
|
||||||
|
logical_keys = [
|
||||||
|
StockStatus.OK.value,
|
||||||
|
StockStatus.ATTENTION.value,
|
||||||
|
StockStatus.DAMAGED.value,
|
||||||
|
StockStatus.DESTROYED.value,
|
||||||
|
StockStatus.REJECTED.value,
|
||||||
|
StockStatus.LOST.value,
|
||||||
|
StockStatus.QUARANTINED.value,
|
||||||
|
StockStatus.RETURNED.value,
|
||||||
|
StockStatus.OK.value,
|
||||||
|
StockStatus.ATTENTION.value,
|
||||||
|
]
|
||||||
|
|
||||||
|
custom_statuses = [
|
||||||
|
InvenTreeCustomUserStateModel.objects.create(
|
||||||
|
key=2000 + i,
|
||||||
|
name=f'StockCustomStatus{i}',
|
||||||
|
label=f'Stock Custom Status Label {i}',
|
||||||
|
color='secondary',
|
||||||
|
logical_key=logical_keys[i],
|
||||||
|
model=stock_content_type,
|
||||||
|
reference_status='StockStatus',
|
||||||
|
)
|
||||||
|
for i in range(10)
|
||||||
|
]
|
||||||
|
|
||||||
|
part = Part.objects.filter(active=True, virtual=False).first()
|
||||||
|
|
||||||
|
# Create 500 stock items, cycling through the 10 custom statuses
|
||||||
|
StockItem.objects.bulk_create([
|
||||||
|
StockItem(
|
||||||
|
part=part,
|
||||||
|
quantity=1,
|
||||||
|
level=0,
|
||||||
|
tree_id=0,
|
||||||
|
lft=0,
|
||||||
|
rght=0,
|
||||||
|
status=custom_statuses[i % 10].logical_key,
|
||||||
|
status_custom_key=custom_statuses[i % 10].key,
|
||||||
|
)
|
||||||
|
for i in range(500)
|
||||||
|
])
|
||||||
|
|
||||||
|
# Lookup: custom_key -> custom_status_object, for quick per-row assertions
|
||||||
|
custom_lookup = {cs.key: cs for cs in custom_statuses}
|
||||||
|
|
||||||
|
# Query count must stay below the fixed threshold regardless of limit.
|
||||||
|
# An N+1 bug would push limit=100 or limit=500 well over the threshold.
|
||||||
|
for limit in [50, 100, 500]:
|
||||||
|
response = self.get(
|
||||||
|
self.list_url,
|
||||||
|
data={'limit': limit},
|
||||||
|
expected_code=200,
|
||||||
|
max_query_count=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
for result in response.data['results']:
|
||||||
|
cs = custom_lookup.get(result['status_custom_key'])
|
||||||
|
|
||||||
|
if cs is None:
|
||||||
|
# Item from fixtures - no custom status assigned
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.assertEqual(result['status'], cs.logical_key)
|
||||||
|
self.assertEqual(result['status_custom_key'], cs.key)
|
||||||
|
self.assertEqual(result['status_text'], cs.label)
|
||||||
|
|
||||||
|
|
||||||
class StockItemTest(StockAPITestCase):
|
class StockItemTest(StockAPITestCase):
|
||||||
"""Series of API tests for the StockItem API."""
|
"""Series of API tests for the StockItem API."""
|
||||||
|
|||||||
Reference in New Issue
Block a user