Skip to content

Commit 5b55bed

Browse files
1337joeSchrodingersGatmatmair
authored
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 <[email protected]> Co-authored-by: Matthias Mair <[email protected]>
1 parent adef0b4 commit 5b55bed

File tree

7 files changed

+60
-25
lines changed

7 files changed

+60
-25
lines changed

src/backend/InvenTree/InvenTree/api_version.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
"""InvenTree API version information."""
22

33
# InvenTree API version
4-
INVENTREE_API_VERSION = 382
4+
INVENTREE_API_VERSION = 383
55

66
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
77

88
INVENTREE_API_TEXT = """
9+
v383 -> 2025-08-08 : https://github.com/inventree/InvenTree/pull/9969
10+
- Correctly apply changes listed in v358
11+
- Breaking: StockCreate now always returns a list of StockItem
912
1013
v382 -> 2025-08-07 : https://github.com/inventree/InvenTree/pull/10146
1114
- Adds ability to "bulk create" test results via the API

src/backend/InvenTree/InvenTree/schema.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,16 @@ def get_operation(
117117
f'{parameter["description"]} Searched fields: {", ".join(search_fields)}.'
118118
)
119119

120+
# Change return to array type, simply annotating this return type attempts to paginate, which doesn't work for
121+
# a create method and removing the pagination also affects the list method
122+
if self.method == 'POST' and type(self.view).__name__ == 'StockList':
123+
schema = operation['responses']['201']['content']['application/json'][
124+
'schema'
125+
]
126+
schema['type'] = 'array'
127+
schema['items'] = {'$ref': schema['$ref']}
128+
del schema['$ref']
129+
120130
return operation
121131

122132

src/backend/InvenTree/build/api.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -631,14 +631,15 @@ def get_serializer_context(self):
631631
return ctx
632632

633633

634+
@extend_schema(responses={201: stock.serializers.StockItemSerializer(many=True)})
634635
class BuildOutputCreate(BuildOrderContextMixin, CreateAPI):
635636
"""API endpoint for creating new build output(s)."""
636637

637638
queryset = Build.objects.none()
638639

639640
serializer_class = build.serializers.BuildOutputCreateSerializer
641+
pagination_class = None
640642

641-
@extend_schema(responses={201: stock.serializers.StockItemSerializer(many=True)})
642643
def create(self, request, *args, **kwargs):
643644
"""Override the create method to handle the creation of build outputs."""
644645
serializer = self.get_serializer(data=request.data)

src/backend/InvenTree/stock/api.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,12 +120,13 @@ def get_serializer_context(self):
120120
return context
121121

122122

123+
@extend_schema(responses={201: StockSerializers.StockItemSerializer(many=True)})
123124
class StockItemSerialize(StockItemContextMixin, CreateAPI):
124125
"""API endpoint for serializing a stock item."""
125126

126127
serializer_class = StockSerializers.SerializeStockItemSerializer
128+
pagination_class = None
127129

128-
@extend_schema(responses={201: StockSerializers.StockItemSerializer(many=True)})
129130
def create(self, request, *args, **kwargs):
130131
"""Serialize the provided StockItem."""
131132
serializer = self.get_serializer(data=request.data)
@@ -1182,7 +1183,7 @@ def create(self, request, *args, **kwargs):
11821183

11831184
item.save(user=user)
11841185

1185-
response_data = serializer.data
1186+
response_data = [serializer.data]
11861187

11871188
return Response(
11881189
response_data,

src/backend/InvenTree/stock/serializers.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,7 @@ class Meta:
371371
'purchase_price',
372372
'purchase_price_currency',
373373
'use_pack_size',
374+
'serial_numbers',
374375
'tests',
375376
# Annotated fields
376377
'allocated',
@@ -402,7 +403,10 @@ class Meta:
402403
"""
403404
Fields used when creating a stock item
404405
"""
405-
extra_kwargs = {'use_pack_size': {'write_only': True}}
406+
extra_kwargs = {
407+
'use_pack_size': {'write_only': True},
408+
'serial_numbers': {'write_only': True},
409+
}
406410

407411
def __init__(self, *args, **kwargs):
408412
"""Add detail fields."""
@@ -467,7 +471,14 @@ def __init__(self, *args, **kwargs):
467471
help_text=_(
468472
'Use pack size when adding: the quantity defined is the number of packs'
469473
),
470-
label=('Use pack size'),
474+
label=_('Use pack size'),
475+
)
476+
477+
serial_numbers = serializers.CharField(
478+
write_only=True,
479+
required=False,
480+
allow_null=True,
481+
help_text=_('Enter serial numbers for new items'),
471482
)
472483

473484
def validate_part(self, part):

src/backend/InvenTree/stock/test_api.py

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1133,9 +1133,9 @@ def test_custom_status(self):
11331133
},
11341134
expected_code=201,
11351135
)
1136-
self.assertEqual(response.data['status'], self.status.logical_key)
1137-
self.assertEqual(response.data['status_custom_key'], self.status.key)
1138-
pk = response.data['pk']
1136+
self.assertEqual(response.data[0]['status'], self.status.logical_key)
1137+
self.assertEqual(response.data[0]['status_custom_key'], self.status.key)
1138+
pk = response.data[0]['pk']
11391139

11401140
# Update the stock item with another custom status code via the API
11411141
response = self.patch(
@@ -1167,8 +1167,8 @@ def test_custom_status(self):
11671167
},
11681168
expected_code=201,
11691169
)
1170-
self.assertEqual(response.data['status'], self.status.logical_key)
1171-
self.assertEqual(response.data['status_custom_key'], self.status.logical_key)
1170+
self.assertEqual(response.data[0]['status'], self.status.logical_key)
1171+
self.assertEqual(response.data[0]['status_custom_key'], self.status.logical_key)
11721172

11731173
# Test case with wrong key
11741174
response = self.patch(
@@ -1216,7 +1216,7 @@ def test_create_default_location(self):
12161216
self.list_url, data={'part': 4, 'quantity': 10}, expected_code=201
12171217
)
12181218

1219-
self.assertEqual(response.data['location'], 2)
1219+
self.assertEqual(response.data[0]['location'], 2)
12201220

12211221
# What if we explicitly set the location to a different value?
12221222

@@ -1225,7 +1225,7 @@ def test_create_default_location(self):
12251225
data={'part': 4, 'quantity': 20, 'location': 1},
12261226
expected_code=201,
12271227
)
1228-
self.assertEqual(response.data['location'], 1)
1228+
self.assertEqual(response.data[0]['location'], 1)
12291229

12301230
# And finally, what if we set the location explicitly to None?
12311231

@@ -1235,7 +1235,7 @@ def test_create_default_location(self):
12351235
expected_code=201,
12361236
)
12371237

1238-
self.assertEqual(response.data['location'], None)
1238+
self.assertEqual(response.data[0]['location'], None)
12391239

12401240
def test_stock_item_create(self):
12411241
"""Test creation of a StockItem via the API."""
@@ -1306,7 +1306,7 @@ def test_stock_item_create_with_supplier_part(self):
13061306
# Reload part, count stock again
13071307
part_4 = part.models.Part.objects.get(pk=4)
13081308
self.assertEqual(part_4.available_stock, current_count + 3)
1309-
stock_4 = StockItem.objects.get(pk=response.data['pk'])
1309+
stock_4 = StockItem.objects.get(pk=response.data[0]['pk'])
13101310
self.assertEqual(stock_4.purchase_price, Money('123.450000', 'USD'))
13111311

13121312
# POST with valid supplier part, no pack size defined
@@ -1330,7 +1330,7 @@ def test_stock_item_create_with_supplier_part(self):
13301330
# Reload part, count stock again
13311331
part_4 = part.models.Part.objects.get(pk=4)
13321332
self.assertEqual(part_4.available_stock, current_count + 12)
1333-
stock_4 = StockItem.objects.get(pk=response.data['pk'])
1333+
stock_4 = StockItem.objects.get(pk=response.data[0]['pk'])
13341334
self.assertEqual(stock_4.purchase_price, Money('123.450000', 'USD'))
13351335

13361336
# POST with valid supplier part, WITH pack size defined - but ignore
@@ -1352,7 +1352,7 @@ def test_stock_item_create_with_supplier_part(self):
13521352
# Reload part, count stock again
13531353
part_4 = part.models.Part.objects.get(pk=4)
13541354
self.assertEqual(part_4.available_stock, current_count + 3)
1355-
stock_4 = StockItem.objects.get(pk=response.data['pk'])
1355+
stock_4 = StockItem.objects.get(pk=response.data[0]['pk'])
13561356
self.assertEqual(stock_4.purchase_price, Money('123.450000', 'USD'))
13571357

13581358
# POST with valid supplier part, WITH pack size defined and used
@@ -1374,7 +1374,7 @@ def test_stock_item_create_with_supplier_part(self):
13741374
# Reload part, count stock again
13751375
part_4 = part.models.Part.objects.get(pk=4)
13761376
self.assertEqual(part_4.available_stock, current_count + 3 * 100)
1377-
stock_4 = StockItem.objects.get(pk=response.data['pk'])
1377+
stock_4 = StockItem.objects.get(pk=response.data[0]['pk'])
13781378
self.assertEqual(stock_4.purchase_price, Money('1.234500', 'USD'))
13791379

13801380
def test_creation_with_serials(self):
@@ -1450,15 +1450,15 @@ def test_default_expiry(self):
14501450

14511451
response = self.post(self.list_url, data, expected_code=201)
14521452

1453-
self.assertIsNone(response.data['expiry_date'])
1453+
self.assertIsNone(response.data[0]['expiry_date'])
14541454

14551455
# Second test - create a new StockItem with an explicit expiry date
14561456
data['expiry_date'] = '2022-12-12'
14571457

14581458
response = self.post(self.list_url, data, expected_code=201)
14591459

1460-
self.assertIsNotNone(response.data['expiry_date'])
1461-
self.assertEqual(response.data['expiry_date'], '2022-12-12')
1460+
self.assertIsNotNone(response.data[0]['expiry_date'])
1461+
self.assertEqual(response.data[0]['expiry_date'], '2022-12-12')
14621462

14631463
# Third test - create a new StockItem for a Part which has a default expiry time
14641464
data = {'part': 25, 'quantity': 10}
@@ -1468,13 +1468,13 @@ def test_default_expiry(self):
14681468
# Expected expiry date is 10 days in the future
14691469
expiry = datetime.now().date() + timedelta(10)
14701470

1471-
self.assertEqual(response.data['expiry_date'], expiry.isoformat())
1471+
self.assertEqual(response.data[0]['expiry_date'], expiry.isoformat())
14721472

14731473
# Test result when sending a blank value
14741474
data['expiry_date'] = None
14751475

14761476
response = self.post(self.list_url, data, expected_code=201)
1477-
self.assertEqual(response.data['expiry_date'], expiry.isoformat())
1477+
self.assertEqual(response.data[0]['expiry_date'], expiry.isoformat())
14781478

14791479
def test_purchase_price(self):
14801480
"""Test that we can correctly read and adjust purchase price information via the API."""
@@ -1843,7 +1843,7 @@ def test_delete(self):
18431843
expected_code=201,
18441844
)
18451845

1846-
pk = response.data['pk']
1846+
pk = response.data[0]['pk']
18471847

18481848
self.assertEqual(StockItem.objects.count(), n + 1)
18491849

src/frontend/src/tables/stock/StockItemTable.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { t } from '@lingui/core/macro';
22
import { Group, Text } from '@mantine/core';
33
import { type ReactNode, useMemo, useState } from 'react';
4+
import { useNavigate } from 'react-router-dom';
45

56
import { ActionButton } from '@lib/components/ActionButton';
67
import { AddItemButton } from '@lib/components/AddItemButton';
78
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
89
import { ModelType } from '@lib/enums/ModelType';
910
import { UserRoles } from '@lib/enums/Roles';
1011
import { apiUrl } from '@lib/functions/Api';
12+
import { getDetailUrl } from '@lib/functions/Navigation';
1113
import type { TableFilter } from '@lib/types/Filters';
1214
import type { TableColumn } from '@lib/types/Tables';
1315
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
@@ -483,6 +485,8 @@ export function StockItemTable({
483485
[settings]
484486
);
485487

488+
const navigate = useNavigate();
489+
486490
const tableColumns = useMemo(
487491
() =>
488492
stockItemTableColumns({
@@ -528,7 +532,12 @@ export function StockItemTable({
528532
},
529533
follow: true,
530534
table: table,
531-
modelType: ModelType.stockitem
535+
onFormSuccess: (response: any) => {
536+
// Returns a list that may contain multiple serialized stock items
537+
// Navigate to the first result
538+
navigate(getDetailUrl(ModelType.stockitem, response[0].pk));
539+
},
540+
successMessage: t`Stock item serialized`
532541
});
533542

534543
const [partsToOrder, setPartsToOrder] = useState<any[]>([]);

0 commit comments

Comments
 (0)