From 5b55bed82bea48780235e217496533a5141bd85a Mon Sep 17 00:00:00 2001 From: Joe Rogers <1337joe@users.noreply.github.com> Date: Fri, 8 Aug 2025 03:13:42 +0200 Subject: [PATCH] Fix annotations for returning serialized StockItems as lists (#9969) * Fix annotations/pagination on StockApi itemSerialize and BuildApi outputCreate * Add (to schema) field to specify serial numbers on create for stock item * Return list on StockItem creation * Update api version * Update test to expect list return when creating stock items * Add note about breaking changes to api version * Add handling for stockitem list return on creation * Update api version --------- Co-authored-by: Oliver Co-authored-by: Matthias Mair --- .../InvenTree/InvenTree/api_version.py | 5 ++- src/backend/InvenTree/InvenTree/schema.py | 10 ++++++ src/backend/InvenTree/build/api.py | 3 +- src/backend/InvenTree/stock/api.py | 5 +-- src/backend/InvenTree/stock/serializers.py | 15 ++++++-- src/backend/InvenTree/stock/test_api.py | 36 +++++++++---------- .../src/tables/stock/StockItemTable.tsx | 11 +++++- 7 files changed, 60 insertions(+), 25 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 2ab51d39b1..362c508030 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 382 +INVENTREE_API_VERSION = 383 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v383 -> 2025-08-08 : https://github.com/inventree/InvenTree/pull/9969 + - Correctly apply changes listed in v358 + - Breaking: StockCreate now always returns a list of StockItem v382 -> 2025-08-07 : https://github.com/inventree/InvenTree/pull/10146 - Adds ability to "bulk create" test results via the API diff --git a/src/backend/InvenTree/InvenTree/schema.py b/src/backend/InvenTree/InvenTree/schema.py index ac805e4f28..89f24aad1b 100644 --- a/src/backend/InvenTree/InvenTree/schema.py +++ b/src/backend/InvenTree/InvenTree/schema.py @@ -117,6 +117,16 @@ class ExtendedAutoSchema(AutoSchema): f'{parameter["description"]} Searched fields: {", ".join(search_fields)}.' ) + # Change return to array type, simply annotating this return type attempts to paginate, which doesn't work for + # a create method and removing the pagination also affects the list method + if self.method == 'POST' and type(self.view).__name__ == 'StockList': + schema = operation['responses']['201']['content']['application/json'][ + 'schema' + ] + schema['type'] = 'array' + schema['items'] = {'$ref': schema['$ref']} + del schema['$ref'] + return operation diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index ee0422a305..4bd5cce971 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -631,14 +631,15 @@ class BuildOrderContextMixin: return ctx +@extend_schema(responses={201: stock.serializers.StockItemSerializer(many=True)}) class BuildOutputCreate(BuildOrderContextMixin, CreateAPI): """API endpoint for creating new build output(s).""" queryset = Build.objects.none() serializer_class = build.serializers.BuildOutputCreateSerializer + pagination_class = None - @extend_schema(responses={201: stock.serializers.StockItemSerializer(many=True)}) def create(self, request, *args, **kwargs): """Override the create method to handle the creation of build outputs.""" serializer = self.get_serializer(data=request.data) diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index a35b299c08..29e83bb7ef 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -120,12 +120,13 @@ class StockItemContextMixin: return context +@extend_schema(responses={201: StockSerializers.StockItemSerializer(many=True)}) class StockItemSerialize(StockItemContextMixin, CreateAPI): """API endpoint for serializing a stock item.""" serializer_class = StockSerializers.SerializeStockItemSerializer + pagination_class = None - @extend_schema(responses={201: StockSerializers.StockItemSerializer(many=True)}) def create(self, request, *args, **kwargs): """Serialize the provided StockItem.""" serializer = self.get_serializer(data=request.data) @@ -1182,7 +1183,7 @@ class StockList(DataExportViewMixin, StockApiMixin, ListCreateDestroyAPIView): item.save(user=user) - response_data = serializer.data + response_data = [serializer.data] return Response( response_data, diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index 4e0e868f69..0374ae3541 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -371,6 +371,7 @@ class StockItemSerializer( 'purchase_price', 'purchase_price_currency', 'use_pack_size', + 'serial_numbers', 'tests', # Annotated fields 'allocated', @@ -402,7 +403,10 @@ class StockItemSerializer( """ Fields used when creating a stock item """ - extra_kwargs = {'use_pack_size': {'write_only': True}} + extra_kwargs = { + 'use_pack_size': {'write_only': True}, + 'serial_numbers': {'write_only': True}, + } def __init__(self, *args, **kwargs): """Add detail fields.""" @@ -467,7 +471,14 @@ class StockItemSerializer( help_text=_( 'Use pack size when adding: the quantity defined is the number of packs' ), - label=('Use pack size'), + label=_('Use pack size'), + ) + + serial_numbers = serializers.CharField( + write_only=True, + required=False, + allow_null=True, + help_text=_('Enter serial numbers for new items'), ) def validate_part(self, part): diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index 825de0cef8..d5c49551ed 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -1133,9 +1133,9 @@ class CustomStockItemStatusTest(StockAPITestCase): }, expected_code=201, ) - self.assertEqual(response.data['status'], self.status.logical_key) - self.assertEqual(response.data['status_custom_key'], self.status.key) - pk = response.data['pk'] + self.assertEqual(response.data[0]['status'], self.status.logical_key) + self.assertEqual(response.data[0]['status_custom_key'], self.status.key) + pk = response.data[0]['pk'] # Update the stock item with another custom status code via the API response = self.patch( @@ -1167,8 +1167,8 @@ class CustomStockItemStatusTest(StockAPITestCase): }, expected_code=201, ) - self.assertEqual(response.data['status'], self.status.logical_key) - self.assertEqual(response.data['status_custom_key'], self.status.logical_key) + self.assertEqual(response.data[0]['status'], self.status.logical_key) + self.assertEqual(response.data[0]['status_custom_key'], self.status.logical_key) # Test case with wrong key response = self.patch( @@ -1216,7 +1216,7 @@ class StockItemTest(StockAPITestCase): self.list_url, data={'part': 4, 'quantity': 10}, expected_code=201 ) - self.assertEqual(response.data['location'], 2) + self.assertEqual(response.data[0]['location'], 2) # What if we explicitly set the location to a different value? @@ -1225,7 +1225,7 @@ class StockItemTest(StockAPITestCase): data={'part': 4, 'quantity': 20, 'location': 1}, expected_code=201, ) - self.assertEqual(response.data['location'], 1) + self.assertEqual(response.data[0]['location'], 1) # And finally, what if we set the location explicitly to None? @@ -1235,7 +1235,7 @@ class StockItemTest(StockAPITestCase): expected_code=201, ) - self.assertEqual(response.data['location'], None) + self.assertEqual(response.data[0]['location'], None) def test_stock_item_create(self): """Test creation of a StockItem via the API.""" @@ -1306,7 +1306,7 @@ class StockItemTest(StockAPITestCase): # Reload part, count stock again part_4 = part.models.Part.objects.get(pk=4) self.assertEqual(part_4.available_stock, current_count + 3) - stock_4 = StockItem.objects.get(pk=response.data['pk']) + stock_4 = StockItem.objects.get(pk=response.data[0]['pk']) self.assertEqual(stock_4.purchase_price, Money('123.450000', 'USD')) # POST with valid supplier part, no pack size defined @@ -1330,7 +1330,7 @@ class StockItemTest(StockAPITestCase): # Reload part, count stock again part_4 = part.models.Part.objects.get(pk=4) self.assertEqual(part_4.available_stock, current_count + 12) - stock_4 = StockItem.objects.get(pk=response.data['pk']) + stock_4 = StockItem.objects.get(pk=response.data[0]['pk']) self.assertEqual(stock_4.purchase_price, Money('123.450000', 'USD')) # POST with valid supplier part, WITH pack size defined - but ignore @@ -1352,7 +1352,7 @@ class StockItemTest(StockAPITestCase): # Reload part, count stock again part_4 = part.models.Part.objects.get(pk=4) self.assertEqual(part_4.available_stock, current_count + 3) - stock_4 = StockItem.objects.get(pk=response.data['pk']) + stock_4 = StockItem.objects.get(pk=response.data[0]['pk']) self.assertEqual(stock_4.purchase_price, Money('123.450000', 'USD')) # POST with valid supplier part, WITH pack size defined and used @@ -1374,7 +1374,7 @@ class StockItemTest(StockAPITestCase): # Reload part, count stock again part_4 = part.models.Part.objects.get(pk=4) self.assertEqual(part_4.available_stock, current_count + 3 * 100) - stock_4 = StockItem.objects.get(pk=response.data['pk']) + stock_4 = StockItem.objects.get(pk=response.data[0]['pk']) self.assertEqual(stock_4.purchase_price, Money('1.234500', 'USD')) def test_creation_with_serials(self): @@ -1450,15 +1450,15 @@ class StockItemTest(StockAPITestCase): response = self.post(self.list_url, data, expected_code=201) - self.assertIsNone(response.data['expiry_date']) + self.assertIsNone(response.data[0]['expiry_date']) # Second test - create a new StockItem with an explicit expiry date data['expiry_date'] = '2022-12-12' response = self.post(self.list_url, data, expected_code=201) - self.assertIsNotNone(response.data['expiry_date']) - self.assertEqual(response.data['expiry_date'], '2022-12-12') + self.assertIsNotNone(response.data[0]['expiry_date']) + self.assertEqual(response.data[0]['expiry_date'], '2022-12-12') # Third test - create a new StockItem for a Part which has a default expiry time data = {'part': 25, 'quantity': 10} @@ -1468,13 +1468,13 @@ class StockItemTest(StockAPITestCase): # Expected expiry date is 10 days in the future expiry = datetime.now().date() + timedelta(10) - self.assertEqual(response.data['expiry_date'], expiry.isoformat()) + self.assertEqual(response.data[0]['expiry_date'], expiry.isoformat()) # Test result when sending a blank value data['expiry_date'] = None response = self.post(self.list_url, data, expected_code=201) - self.assertEqual(response.data['expiry_date'], expiry.isoformat()) + self.assertEqual(response.data[0]['expiry_date'], expiry.isoformat()) def test_purchase_price(self): """Test that we can correctly read and adjust purchase price information via the API.""" @@ -1843,7 +1843,7 @@ class StockItemDeletionTest(StockAPITestCase): expected_code=201, ) - pk = response.data['pk'] + pk = response.data[0]['pk'] self.assertEqual(StockItem.objects.count(), n + 1) diff --git a/src/frontend/src/tables/stock/StockItemTable.tsx b/src/frontend/src/tables/stock/StockItemTable.tsx index 0e7f8c4523..d91c1afcb1 100644 --- a/src/frontend/src/tables/stock/StockItemTable.tsx +++ b/src/frontend/src/tables/stock/StockItemTable.tsx @@ -1,6 +1,7 @@ import { t } from '@lingui/core/macro'; import { Group, Text } from '@mantine/core'; import { type ReactNode, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { ActionButton } from '@lib/components/ActionButton'; import { AddItemButton } from '@lib/components/AddItemButton'; @@ -8,6 +9,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; import { apiUrl } from '@lib/functions/Api'; +import { getDetailUrl } from '@lib/functions/Navigation'; import type { TableFilter } from '@lib/types/Filters'; import type { TableColumn } from '@lib/types/Tables'; import OrderPartsWizard from '../../components/wizards/OrderPartsWizard'; @@ -483,6 +485,8 @@ export function StockItemTable({ [settings] ); + const navigate = useNavigate(); + const tableColumns = useMemo( () => stockItemTableColumns({ @@ -528,7 +532,12 @@ export function StockItemTable({ }, follow: true, table: table, - modelType: ModelType.stockitem + onFormSuccess: (response: any) => { + // Returns a list that may contain multiple serialized stock items + // Navigate to the first result + navigate(getDetailUrl(ModelType.stockitem, response[0].pk)); + }, + successMessage: t`Stock item serialized` }); const [partsToOrder, setPartsToOrder] = useState([]);