diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index 8a1fe3475c..bd862fdb25 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -977,10 +977,23 @@ class UninstallStockItemSerializer(serializers.Serializer): class StockStatusCustomSerializer(serializers.ChoiceField): """Serializer to allow annotating the schema to use int where custom values may be entered.""" - def __init__(self, *args, **kwargs): - """Initialize the status selector.""" + def __init__(self, *args, include_no_change: bool = False, **kwargs): + """Initialize the status selector. + + Arguments: + include_no_change: If True, prepend a null "No Change" option to the choices + + Note: Choices are evaluated here (rather than being passed in) so that + custom states which are added at runtime are available. Declared serializer + fields are deep-copied (re-initialized) for each serializer instance. + """ if 'choices' not in kwargs: - kwargs['choices'] = stock.status_codes.StockStatus.items(custom=True) + choices = list(stock.status_codes.StockStatus.items(custom=True)) + + if include_no_change: + choices = [(None, _('No Change')), *choices] + + kwargs['choices'] = choices if 'label' not in kwargs: kwargs['label'] = _('Status') @@ -1753,14 +1766,6 @@ class StockMergeSerializer(serializers.Serializer): ) -def stock_item_adjust_status_options(): - """Return a custom set of options for the StockItem status adjustment field. - - In particular, include a Null option for the status field. - """ - return [(None, _('No Change')), *stock.status_codes.StockStatus.items(custom=True)] - - class StockAdjustmentItemSerializer(serializers.Serializer): """Serializer for a single StockItem within a stock adjustment request. @@ -1840,10 +1845,7 @@ class StockAdjustmentItemSerializer(serializers.Serializer): ) status = StockStatusCustomSerializer( - choices=stock_item_adjust_status_options(), - default=None, - required=False, - allow_blank=True, + include_no_change=True, default=None, required=False, allow_blank=True ) packaging = serializers.CharField( diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index 11711f376c..f7c36aca2b 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -3403,6 +3403,164 @@ class StocktakeTest(StockAPITestCase): self.assertEqual(len(response.data['items']), 100) +class StockAdjustmentCustomStatusTest(StockAPITestCase): + """Tests for applying custom status values via the stock adjustment endpoints. + + Regression tests for https://github.com/inventree/InvenTree/issues/13191 + The available status choices for stock adjustment items were evaluated once + (at import time), so custom states created afterwards were rejected as invalid. + """ + + def setUp(self): + """Create a custom stock status *after* the serializer module has been imported.""" + super().setUp() + + self.custom_status = InvenTreeCustomUserStateModel.objects.create( + key=160, + name='Custom attention', + label='Custom attention', + color='warning', + logical_key=StockStatus.ATTENTION.value, + model=ContentType.objects.get(model='stockitem'), + reference_status='StockStatus', + ) + + self.part = Part.objects.get(pk=1) + self.source = StockLocation.objects.get(pk=5) + self.dest = StockLocation.objects.get(pk=2) + + def test_serializer_choices(self): + """The adjustment item serializer must offer the runtime custom state.""" + from stock.serializers import StockAdjustmentItemSerializer + + choices = StockAdjustmentItemSerializer().fields['status'].choices + + self.assertIn(None, choices) + self.assertIn(StockStatus.OK.value, choices) + self.assertIn(self.custom_status.key, choices) + + # A state created later must also be picked up by a new serializer instance + later_status = InvenTreeCustomUserStateModel.objects.create( + key=170, + name='Custom later', + label='Custom later', + color='info', + logical_key=StockStatus.OK.value, + model=ContentType.objects.get(model='stockitem'), + reference_status='StockStatus', + ) + + choices = StockAdjustmentItemSerializer().fields['status'].choices + self.assertIn(later_status.key, choices) + + def test_adjust_with_custom_status(self): + """Each stock adjustment endpoint must accept a custom status.""" + # Note: transfer the full quantity, so that the original item is moved + for endpoint, quantity, extra in [ + ('api-stock-transfer', 10, {'location': self.dest.pk}), + ('api-stock-count', 5, {}), + ('api-stock-add', 5, {}), + ('api-stock-remove', 5, {}), + ]: + with self.subTest(endpoint=endpoint): + item = StockItem.objects.create( + part=self.part, location=self.source, quantity=10 + ) + + self.assertEqual(item.status, StockStatus.OK.value) + + self.post( + reverse(endpoint), + { + 'items': [ + { + 'pk': item.pk, + 'quantity': quantity, + 'status': self.custom_status.key, + } + ], + 'notes': 'Custom status adjustment', + **extra, + }, + expected_code=201, + ) + + item.refresh_from_db() + self.assertEqual(item.status_custom_key, self.custom_status.key) + self.assertEqual(item.status, StockStatus.ATTENTION.value) + + def test_partial_transfer_with_custom_status(self): + """A partial transfer applies the custom status to the split item only.""" + item = StockItem.objects.create( + part=self.part, location=self.source, quantity=10 + ) + + self.post( + reverse('api-stock-transfer'), + { + 'items': [ + {'pk': item.pk, 'quantity': 4, 'status': self.custom_status.key} + ], + 'location': self.dest.pk, + }, + expected_code=201, + ) + + item.refresh_from_db() + self.assertEqual(item.quantity, 6) + self.assertEqual(item.location, self.source) + self.assertEqual(item.status, StockStatus.OK.value) + self.assertIsNone(item.status_custom_key) + + new_item = StockItem.objects.get( + part=self.part, location=self.dest, parent=item + ) + self.assertEqual(new_item.quantity, 4) + self.assertEqual(new_item.location, self.dest) + self.assertEqual(new_item.status_custom_key, self.custom_status.key) + self.assertEqual(new_item.status, StockStatus.ATTENTION.value) + + def test_invalid_status_rejected(self): + """Unknown status values must still be rejected.""" + item = StockItem.objects.create( + part=self.part, location=self.source, quantity=10 + ) + + response = self.post( + reverse('api-stock-transfer'), + { + 'items': [{'pk': item.pk, 'quantity': 5, 'status': 999}], + 'location': self.dest.pk, + }, + expected_code=400, + ) + + self.assertIn('is not a valid choice', str(response.data)) + + item.refresh_from_db() + self.assertEqual(item.location, self.source) + self.assertEqual(item.quantity, 10) + + def test_no_status_change(self): + """Omitting the status (or passing a blank value) leaves the status unchanged.""" + item = StockItem.objects.create( + part=self.part, location=self.source, quantity=10 + ) + item.set_status(self.custom_status.key) + item.save() + + for idx, line in enumerate([{}, {'status': ''}]): + with self.subTest(line=line): + self.post( + reverse('api-stock-count'), + {'items': [{'pk': item.pk, 'quantity': 8 - idx, **line}]}, + expected_code=201, + ) + + item.refresh_from_db() + self.assertEqual(item.status_custom_key, self.custom_status.key) + + class StockTransferMergeTest(StockAPITestCase): """Tests for optional merge-on-transfer behavior."""