mirror of
https://github.com/inventree/InvenTree.git
synced 2025-09-13 14:11:37 +00:00
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 <oliver.henry.walters@gmail.com> Co-authored-by: Matthias Mair <code@mjmair.com>
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 = 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."""
|
"""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 = """
|
||||||
|
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
|
v382 -> 2025-08-07 : https://github.com/inventree/InvenTree/pull/10146
|
||||||
- Adds ability to "bulk create" test results via the API
|
- Adds ability to "bulk create" test results via the API
|
||||||
|
@@ -117,6 +117,16 @@ class ExtendedAutoSchema(AutoSchema):
|
|||||||
f'{parameter["description"]} Searched fields: {", ".join(search_fields)}.'
|
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
|
return operation
|
||||||
|
|
||||||
|
|
||||||
|
@@ -631,14 +631,15 @@ class BuildOrderContextMixin:
|
|||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(responses={201: stock.serializers.StockItemSerializer(many=True)})
|
||||||
class BuildOutputCreate(BuildOrderContextMixin, CreateAPI):
|
class BuildOutputCreate(BuildOrderContextMixin, CreateAPI):
|
||||||
"""API endpoint for creating new build output(s)."""
|
"""API endpoint for creating new build output(s)."""
|
||||||
|
|
||||||
queryset = Build.objects.none()
|
queryset = Build.objects.none()
|
||||||
|
|
||||||
serializer_class = build.serializers.BuildOutputCreateSerializer
|
serializer_class = build.serializers.BuildOutputCreateSerializer
|
||||||
|
pagination_class = None
|
||||||
|
|
||||||
@extend_schema(responses={201: stock.serializers.StockItemSerializer(many=True)})
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
"""Override the create method to handle the creation of build outputs."""
|
"""Override the create method to handle the creation of build outputs."""
|
||||||
serializer = self.get_serializer(data=request.data)
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
@@ -120,12 +120,13 @@ class StockItemContextMixin:
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(responses={201: StockSerializers.StockItemSerializer(many=True)})
|
||||||
class StockItemSerialize(StockItemContextMixin, CreateAPI):
|
class StockItemSerialize(StockItemContextMixin, CreateAPI):
|
||||||
"""API endpoint for serializing a stock item."""
|
"""API endpoint for serializing a stock item."""
|
||||||
|
|
||||||
serializer_class = StockSerializers.SerializeStockItemSerializer
|
serializer_class = StockSerializers.SerializeStockItemSerializer
|
||||||
|
pagination_class = None
|
||||||
|
|
||||||
@extend_schema(responses={201: StockSerializers.StockItemSerializer(many=True)})
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
"""Serialize the provided StockItem."""
|
"""Serialize the provided StockItem."""
|
||||||
serializer = self.get_serializer(data=request.data)
|
serializer = self.get_serializer(data=request.data)
|
||||||
@@ -1182,7 +1183,7 @@ class StockList(DataExportViewMixin, StockApiMixin, ListCreateDestroyAPIView):
|
|||||||
|
|
||||||
item.save(user=user)
|
item.save(user=user)
|
||||||
|
|
||||||
response_data = serializer.data
|
response_data = [serializer.data]
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
response_data,
|
response_data,
|
||||||
|
@@ -371,6 +371,7 @@ class StockItemSerializer(
|
|||||||
'purchase_price',
|
'purchase_price',
|
||||||
'purchase_price_currency',
|
'purchase_price_currency',
|
||||||
'use_pack_size',
|
'use_pack_size',
|
||||||
|
'serial_numbers',
|
||||||
'tests',
|
'tests',
|
||||||
# Annotated fields
|
# Annotated fields
|
||||||
'allocated',
|
'allocated',
|
||||||
@@ -402,7 +403,10 @@ class StockItemSerializer(
|
|||||||
"""
|
"""
|
||||||
Fields used when creating a stock item
|
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):
|
def __init__(self, *args, **kwargs):
|
||||||
"""Add detail fields."""
|
"""Add detail fields."""
|
||||||
@@ -467,7 +471,14 @@ class StockItemSerializer(
|
|||||||
help_text=_(
|
help_text=_(
|
||||||
'Use pack size when adding: the quantity defined is the number of packs'
|
'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):
|
def validate_part(self, part):
|
||||||
|
@@ -1133,9 +1133,9 @@ class CustomStockItemStatusTest(StockAPITestCase):
|
|||||||
},
|
},
|
||||||
expected_code=201,
|
expected_code=201,
|
||||||
)
|
)
|
||||||
self.assertEqual(response.data['status'], self.status.logical_key)
|
self.assertEqual(response.data[0]['status'], self.status.logical_key)
|
||||||
self.assertEqual(response.data['status_custom_key'], self.status.key)
|
self.assertEqual(response.data[0]['status_custom_key'], self.status.key)
|
||||||
pk = response.data['pk']
|
pk = response.data[0]['pk']
|
||||||
|
|
||||||
# Update the stock item with another custom status code via the API
|
# Update the stock item with another custom status code via the API
|
||||||
response = self.patch(
|
response = self.patch(
|
||||||
@@ -1167,8 +1167,8 @@ class CustomStockItemStatusTest(StockAPITestCase):
|
|||||||
},
|
},
|
||||||
expected_code=201,
|
expected_code=201,
|
||||||
)
|
)
|
||||||
self.assertEqual(response.data['status'], self.status.logical_key)
|
self.assertEqual(response.data[0]['status'], self.status.logical_key)
|
||||||
self.assertEqual(response.data['status_custom_key'], self.status.logical_key)
|
self.assertEqual(response.data[0]['status_custom_key'], self.status.logical_key)
|
||||||
|
|
||||||
# Test case with wrong key
|
# Test case with wrong key
|
||||||
response = self.patch(
|
response = self.patch(
|
||||||
@@ -1216,7 +1216,7 @@ class StockItemTest(StockAPITestCase):
|
|||||||
self.list_url, data={'part': 4, 'quantity': 10}, expected_code=201
|
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?
|
# 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},
|
data={'part': 4, 'quantity': 20, 'location': 1},
|
||||||
expected_code=201,
|
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?
|
# And finally, what if we set the location explicitly to None?
|
||||||
|
|
||||||
@@ -1235,7 +1235,7 @@ class StockItemTest(StockAPITestCase):
|
|||||||
expected_code=201,
|
expected_code=201,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.data['location'], None)
|
self.assertEqual(response.data[0]['location'], None)
|
||||||
|
|
||||||
def test_stock_item_create(self):
|
def test_stock_item_create(self):
|
||||||
"""Test creation of a StockItem via the API."""
|
"""Test creation of a StockItem via the API."""
|
||||||
@@ -1306,7 +1306,7 @@ class StockItemTest(StockAPITestCase):
|
|||||||
# Reload part, count stock again
|
# Reload part, count stock again
|
||||||
part_4 = part.models.Part.objects.get(pk=4)
|
part_4 = part.models.Part.objects.get(pk=4)
|
||||||
self.assertEqual(part_4.available_stock, current_count + 3)
|
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'))
|
self.assertEqual(stock_4.purchase_price, Money('123.450000', 'USD'))
|
||||||
|
|
||||||
# POST with valid supplier part, no pack size defined
|
# POST with valid supplier part, no pack size defined
|
||||||
@@ -1330,7 +1330,7 @@ class StockItemTest(StockAPITestCase):
|
|||||||
# Reload part, count stock again
|
# Reload part, count stock again
|
||||||
part_4 = part.models.Part.objects.get(pk=4)
|
part_4 = part.models.Part.objects.get(pk=4)
|
||||||
self.assertEqual(part_4.available_stock, current_count + 12)
|
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'))
|
self.assertEqual(stock_4.purchase_price, Money('123.450000', 'USD'))
|
||||||
|
|
||||||
# POST with valid supplier part, WITH pack size defined - but ignore
|
# POST with valid supplier part, WITH pack size defined - but ignore
|
||||||
@@ -1352,7 +1352,7 @@ class StockItemTest(StockAPITestCase):
|
|||||||
# Reload part, count stock again
|
# Reload part, count stock again
|
||||||
part_4 = part.models.Part.objects.get(pk=4)
|
part_4 = part.models.Part.objects.get(pk=4)
|
||||||
self.assertEqual(part_4.available_stock, current_count + 3)
|
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'))
|
self.assertEqual(stock_4.purchase_price, Money('123.450000', 'USD'))
|
||||||
|
|
||||||
# POST with valid supplier part, WITH pack size defined and used
|
# POST with valid supplier part, WITH pack size defined and used
|
||||||
@@ -1374,7 +1374,7 @@ class StockItemTest(StockAPITestCase):
|
|||||||
# Reload part, count stock again
|
# Reload part, count stock again
|
||||||
part_4 = part.models.Part.objects.get(pk=4)
|
part_4 = part.models.Part.objects.get(pk=4)
|
||||||
self.assertEqual(part_4.available_stock, current_count + 3 * 100)
|
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'))
|
self.assertEqual(stock_4.purchase_price, Money('1.234500', 'USD'))
|
||||||
|
|
||||||
def test_creation_with_serials(self):
|
def test_creation_with_serials(self):
|
||||||
@@ -1450,15 +1450,15 @@ class StockItemTest(StockAPITestCase):
|
|||||||
|
|
||||||
response = self.post(self.list_url, data, expected_code=201)
|
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
|
# Second test - create a new StockItem with an explicit expiry date
|
||||||
data['expiry_date'] = '2022-12-12'
|
data['expiry_date'] = '2022-12-12'
|
||||||
|
|
||||||
response = self.post(self.list_url, data, expected_code=201)
|
response = self.post(self.list_url, data, expected_code=201)
|
||||||
|
|
||||||
self.assertIsNotNone(response.data['expiry_date'])
|
self.assertIsNotNone(response.data[0]['expiry_date'])
|
||||||
self.assertEqual(response.data['expiry_date'], '2022-12-12')
|
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
|
# Third test - create a new StockItem for a Part which has a default expiry time
|
||||||
data = {'part': 25, 'quantity': 10}
|
data = {'part': 25, 'quantity': 10}
|
||||||
@@ -1468,13 +1468,13 @@ class StockItemTest(StockAPITestCase):
|
|||||||
# Expected expiry date is 10 days in the future
|
# Expected expiry date is 10 days in the future
|
||||||
expiry = datetime.now().date() + timedelta(10)
|
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
|
# Test result when sending a blank value
|
||||||
data['expiry_date'] = None
|
data['expiry_date'] = None
|
||||||
|
|
||||||
response = self.post(self.list_url, data, expected_code=201)
|
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):
|
def test_purchase_price(self):
|
||||||
"""Test that we can correctly read and adjust purchase price information via the API."""
|
"""Test that we can correctly read and adjust purchase price information via the API."""
|
||||||
@@ -1843,7 +1843,7 @@ class StockItemDeletionTest(StockAPITestCase):
|
|||||||
expected_code=201,
|
expected_code=201,
|
||||||
)
|
)
|
||||||
|
|
||||||
pk = response.data['pk']
|
pk = response.data[0]['pk']
|
||||||
|
|
||||||
self.assertEqual(StockItem.objects.count(), n + 1)
|
self.assertEqual(StockItem.objects.count(), n + 1)
|
||||||
|
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import { Group, Text } from '@mantine/core';
|
import { Group, Text } from '@mantine/core';
|
||||||
import { type ReactNode, useMemo, useState } from 'react';
|
import { type ReactNode, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { ActionButton } from '@lib/components/ActionButton';
|
import { ActionButton } from '@lib/components/ActionButton';
|
||||||
import { AddItemButton } from '@lib/components/AddItemButton';
|
import { AddItemButton } from '@lib/components/AddItemButton';
|
||||||
@@ -8,6 +9,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
|||||||
import { ModelType } from '@lib/enums/ModelType';
|
import { ModelType } from '@lib/enums/ModelType';
|
||||||
import { UserRoles } from '@lib/enums/Roles';
|
import { UserRoles } from '@lib/enums/Roles';
|
||||||
import { apiUrl } from '@lib/functions/Api';
|
import { apiUrl } from '@lib/functions/Api';
|
||||||
|
import { getDetailUrl } from '@lib/functions/Navigation';
|
||||||
import type { TableFilter } from '@lib/types/Filters';
|
import type { TableFilter } from '@lib/types/Filters';
|
||||||
import type { TableColumn } from '@lib/types/Tables';
|
import type { TableColumn } from '@lib/types/Tables';
|
||||||
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
|
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
|
||||||
@@ -483,6 +485,8 @@ export function StockItemTable({
|
|||||||
[settings]
|
[settings]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const tableColumns = useMemo(
|
const tableColumns = useMemo(
|
||||||
() =>
|
() =>
|
||||||
stockItemTableColumns({
|
stockItemTableColumns({
|
||||||
@@ -528,7 +532,12 @@ export function StockItemTable({
|
|||||||
},
|
},
|
||||||
follow: true,
|
follow: true,
|
||||||
table: table,
|
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<any[]>([]);
|
const [partsToOrder, setPartsToOrder] = useState<any[]>([]);
|
||||||
|
Reference in New Issue
Block a user