mirror of
https://github.com/inventree/InvenTree.git
synced 2025-10-14 21:22:20 +00:00
Refactor API endpoint: Stock (5/6) (#10444)
* add output options for StockLocation and StockItemTestResult endpoints * add output options tests for StockLocation and StockItemTestResult endpoints * add filters for include_installed and stock_item in StockItemTestResultFilter; update tests for new filter options * update stock test.py * update api_version.py * fix test_installed_tests * Enhance error handling and add tests for stock_item and include_installed filters --------- Co-authored-by: Oliver <oliver.henry.walters@gmail.com> Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
@@ -1,12 +1,18 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 396
|
||||
INVENTREE_API_VERSION = 397
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v397 -> 2025-10-01 : https://github.com/inventree/InvenTree/pull/10444
|
||||
- Refactors 'path_detail' param in StockLocation API endpoint
|
||||
- Refactors 'user_detail' and 'template_detail' params in StockItemTestResult API endpoint
|
||||
- Refactors 'item_detail' and 'user_detail' params in StockTracking API endpoint
|
||||
- Refactors 'include_installed' and 'stock_item' params in StockItemTestResult API endpoint
|
||||
|
||||
v396 -> 2025-10-01 : https://github.com/inventree/InvenTree/pull/10443
|
||||
- Refactors 'part_detail', 'manufacturer_detail' and 'pretty' params in Manufacturer API endpoint
|
||||
- Refactors 'manufacturer_part_detail' param in ManufacturerPartParameter API endpoint
|
||||
|
@@ -35,6 +35,7 @@ from InvenTree.api import (
|
||||
ListCreateDestroyAPIView,
|
||||
MetadataView,
|
||||
)
|
||||
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
|
||||
from InvenTree.filters import (
|
||||
ORDER_FILTER_ALIAS,
|
||||
SEARCH_ORDER_FILTER,
|
||||
@@ -48,6 +49,7 @@ from InvenTree.mixins import (
|
||||
CustomRetrieveUpdateDestroyAPI,
|
||||
ListAPI,
|
||||
ListCreateAPI,
|
||||
OutputOptionsMixin,
|
||||
RetrieveAPI,
|
||||
RetrieveUpdateDestroyAPI,
|
||||
)
|
||||
@@ -379,12 +381,6 @@ class StockLocationMixin:
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Set context before returning serializer."""
|
||||
try:
|
||||
params = self.request.query_params
|
||||
kwargs['path_detail'] = str2bool(params.get('path_detail', False))
|
||||
except AttributeError: # pragma: no cover
|
||||
pass
|
||||
|
||||
kwargs['context'] = self.get_serializer_context()
|
||||
|
||||
return super().get_serializer(*args, **kwargs)
|
||||
@@ -396,8 +392,23 @@ class StockLocationMixin:
|
||||
return queryset
|
||||
|
||||
|
||||
class StockLocationOutputOptions(OutputConfiguration):
|
||||
"""Output options for StockLocation serializers."""
|
||||
|
||||
OPTIONS = [
|
||||
InvenTreeOutputOption(
|
||||
description='Include detailed information about the BOM item linked to this build line.',
|
||||
flag='path_detail',
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class StockLocationList(
|
||||
DataExportViewMixin, BulkUpdateMixin, StockLocationMixin, ListCreateAPI
|
||||
DataExportViewMixin,
|
||||
BulkUpdateMixin,
|
||||
StockLocationMixin,
|
||||
OutputOptionsMixin,
|
||||
ListCreateAPI,
|
||||
):
|
||||
"""API endpoint for list view of StockLocation objects.
|
||||
|
||||
@@ -407,6 +418,7 @@ class StockLocationList(
|
||||
|
||||
filterset_class = StockLocationFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
output_options = StockLocationOutputOptions
|
||||
|
||||
search_fields = ['name', 'description', 'pathstring', 'tags__name', 'tags__slug']
|
||||
|
||||
@@ -415,9 +427,13 @@ class StockLocationList(
|
||||
ordering = ['tree_id', 'lft', 'name']
|
||||
|
||||
|
||||
class StockLocationDetail(StockLocationMixin, CustomRetrieveUpdateDestroyAPI):
|
||||
class StockLocationDetail(
|
||||
StockLocationMixin, OutputOptionsMixin, CustomRetrieveUpdateDestroyAPI
|
||||
):
|
||||
"""API endpoint for detail view of StockLocation object."""
|
||||
|
||||
output_options = StockLocationOutputOptions
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Delete a Stock location instance via the API."""
|
||||
delete_stock_items = str(request.data.get('delete_stock_items', 0)) == '1'
|
||||
@@ -1017,30 +1033,7 @@ class StockApiMixin:
|
||||
return ctx
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Set context before returning serializer.
|
||||
|
||||
Extra detail may be provided to the serializer via query parameters:
|
||||
|
||||
- part_detail: Include detail about the StockItem's part
|
||||
- location_detail: Include detail about the StockItem's location
|
||||
- supplier_part_detail: Include detail about the StockItem's supplier_part
|
||||
- tests: Include detail about the StockItem's test results
|
||||
"""
|
||||
try:
|
||||
params = self.request.query_params
|
||||
|
||||
kwargs['part_detail'] = str2bool(params.get('part_detail', True))
|
||||
|
||||
for key in [
|
||||
'path_detail',
|
||||
'location_detail',
|
||||
'supplier_part_detail',
|
||||
'tests',
|
||||
]:
|
||||
kwargs[key] = str2bool(params.get(key, False))
|
||||
except AttributeError: # pragma: no cover
|
||||
pass
|
||||
|
||||
"""Set context before returning serializer."""
|
||||
kwargs['context'] = self.get_serializer_context()
|
||||
|
||||
return super().get_serializer(*args, **kwargs)
|
||||
@@ -1321,24 +1314,27 @@ class StockItemTestResultMixin:
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Set context before returning serializer."""
|
||||
try:
|
||||
kwargs['user_detail'] = str2bool(
|
||||
self.request.query_params.get('user_detail', False)
|
||||
)
|
||||
kwargs['template_detail'] = str2bool(
|
||||
self.request.query_params.get('template_detail', False)
|
||||
)
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
|
||||
kwargs['context'] = self.get_serializer_context()
|
||||
|
||||
return super().get_serializer(*args, **kwargs)
|
||||
|
||||
|
||||
class StockItemTestResultDetail(StockItemTestResultMixin, RetrieveUpdateDestroyAPI):
|
||||
class StockItemTestResultOutputOptions(OutputConfiguration):
|
||||
"""Output options for StockItemTestResult endpoint."""
|
||||
|
||||
OPTIONS = [
|
||||
InvenTreeOutputOption(flag='user_detail'),
|
||||
InvenTreeOutputOption(flag='template_detail'),
|
||||
]
|
||||
|
||||
|
||||
class StockItemTestResultDetail(
|
||||
StockItemTestResultMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI
|
||||
):
|
||||
"""Detail endpoint for StockItemTestResult."""
|
||||
|
||||
output_options = StockItemTestResultOutputOptions
|
||||
|
||||
|
||||
class StockItemTestResultFilter(FilterSet):
|
||||
"""API filter for the StockItemTestResult list."""
|
||||
@@ -1381,52 +1377,71 @@ class StockItemTestResultFilter(FilterSet):
|
||||
key = generateTestKey(value)
|
||||
return queryset.filter(template__key=key)
|
||||
|
||||
include_installed = rest_filters.BooleanFilter(
|
||||
method='filter_include_installed',
|
||||
label=_('Include Installed'),
|
||||
help_text=_(
|
||||
'If true, include test results for items installed underneath the given stock item'
|
||||
),
|
||||
)
|
||||
|
||||
stock_item = rest_filters.NumberFilter(
|
||||
method='filter_stock_item',
|
||||
label=_('Stock Item'),
|
||||
help_text=_('Filter by numeric Stock Item ID'),
|
||||
)
|
||||
|
||||
def filter_include_installed(self, queryset, name, value):
|
||||
"""Dummy filter method for 'include_installed'.
|
||||
|
||||
- Ensures 'include_installed' appears in API documentation
|
||||
- Does NOT actually filter the queryset directly
|
||||
- The actual logic is handled in filter_stock_item method
|
||||
"""
|
||||
return queryset
|
||||
|
||||
def filter_stock_item(self, queryset, name, value):
|
||||
"""Filter for stock_item that also applies include_installed logic."""
|
||||
include_installed = str2bool(self.data.get('include_installed', False))
|
||||
|
||||
try:
|
||||
item = StockItem.objects.get(pk=value)
|
||||
|
||||
except StockItem.DoesNotExist:
|
||||
raise ValidationError({
|
||||
'stock_item': _('Stock item with ID {id} does not exist').format(
|
||||
id=value
|
||||
)
|
||||
})
|
||||
|
||||
items = [item]
|
||||
|
||||
if include_installed:
|
||||
# Include items which are installed "underneath" this item
|
||||
# Note that this function is recursive!
|
||||
installed_items = item.get_installed_items(cascade=True)
|
||||
items += list(installed_items)
|
||||
|
||||
return queryset.filter(stock_item__in=items)
|
||||
|
||||
|
||||
class StockItemTestResultList(
|
||||
BulkCreateMixin, StockItemTestResultMixin, ListCreateDestroyAPIView
|
||||
BulkCreateMixin,
|
||||
StockItemTestResultMixin,
|
||||
OutputOptionsMixin,
|
||||
ListCreateDestroyAPIView,
|
||||
):
|
||||
"""API endpoint for listing (and creating) a StockItemTestResult object."""
|
||||
|
||||
filterset_class = StockItemTestResultFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
output_options = StockItemTestResultOutputOptions
|
||||
|
||||
filterset_fields = ['user', 'template', 'result', 'value']
|
||||
ordering_fields = ['date', 'result']
|
||||
|
||||
ordering = 'date'
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""Filter by build or stock_item."""
|
||||
params = self.request.query_params
|
||||
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
# Filter by stock item
|
||||
item = params.get('stock_item', None)
|
||||
|
||||
if item is not None:
|
||||
try:
|
||||
item = StockItem.objects.get(pk=item)
|
||||
|
||||
items = [item]
|
||||
|
||||
# Do we wish to also include test results for 'installed' items?
|
||||
include_installed = str2bool(params.get('include_installed', False))
|
||||
|
||||
if include_installed:
|
||||
# Include items which are installed "underneath" this item
|
||||
# Note that this function is recursive!
|
||||
installed_items = item.get_installed_items(cascade=True)
|
||||
|
||||
items += list(installed_items)
|
||||
|
||||
queryset = queryset.filter(stock_item__in=items)
|
||||
|
||||
except (ValueError, StockItem.DoesNotExist): # pragma: no cover
|
||||
pass
|
||||
|
||||
return queryset
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Create a new test result object.
|
||||
|
||||
@@ -1446,7 +1461,16 @@ class StockTrackingDetail(RetrieveAPI):
|
||||
serializer_class = StockSerializers.StockTrackingSerializer
|
||||
|
||||
|
||||
class StockTrackingList(DataExportViewMixin, ListAPI):
|
||||
class StockTrackingOutputOptions(OutputConfiguration):
|
||||
"""Output options for StockItemTracking endpoint."""
|
||||
|
||||
OPTIONS = [
|
||||
InvenTreeOutputOption(flag='item_detail'),
|
||||
InvenTreeOutputOption(flag='user_detail'),
|
||||
]
|
||||
|
||||
|
||||
class StockTrackingList(DataExportViewMixin, OutputOptionsMixin, ListAPI):
|
||||
"""API endpoint for list view of StockItemTracking objects.
|
||||
|
||||
StockItemTracking objects are read-only
|
||||
@@ -1457,23 +1481,10 @@ class StockTrackingList(DataExportViewMixin, ListAPI):
|
||||
|
||||
queryset = StockItemTracking.objects.all()
|
||||
serializer_class = StockSerializers.StockTrackingSerializer
|
||||
output_options = StockTrackingOutputOptions
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Set context before returning serializer."""
|
||||
try:
|
||||
kwargs['item_detail'] = str2bool(
|
||||
self.request.query_params.get('item_detail', False)
|
||||
)
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
|
||||
try:
|
||||
kwargs['user_detail'] = str2bool(
|
||||
self.request.query_params.get('user_detail', False)
|
||||
)
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
|
||||
kwargs['context'] = self.get_serializer_context()
|
||||
|
||||
return super().get_serializer(*args, **kwargs)
|
||||
|
@@ -263,6 +263,7 @@
|
||||
level: 0
|
||||
lft: 1
|
||||
rght: 2
|
||||
belongs_to: 105
|
||||
|
||||
# Multiple stock items for "Bob" (PK 100)
|
||||
- model: stock.stockitem
|
||||
|
@@ -263,6 +263,16 @@ class StockLocationTest(StockAPITestCase):
|
||||
child.refresh_from_db()
|
||||
self.assertEqual(child.parent, parent_stock_location)
|
||||
|
||||
def test_output_options(self):
|
||||
"""Test output options."""
|
||||
url = reverse('api-location-detail', kwargs={'pk': 1})
|
||||
|
||||
response = self.get(url, {'path_detail': 'true'}, expected_code=200)
|
||||
self.assertIn('path', response.data)
|
||||
|
||||
response = self.get(url, {'path_detail': 'false'}, expected_code=200)
|
||||
self.assertNotIn('path', response.data)
|
||||
|
||||
def test_stock_location_structural(self):
|
||||
"""Test the effectiveness of structural stock locations.
|
||||
|
||||
@@ -777,18 +787,18 @@ class StockItemListTest(StockAPITestCase):
|
||||
def test_filter_installed(self):
|
||||
"""Filter StockItem by installed."""
|
||||
response = self.get_stock(installed=True)
|
||||
self.assertEqual(len(response), 0)
|
||||
self.assertEqual(len(response), 1)
|
||||
|
||||
response = self.get_stock(installed=False)
|
||||
self.assertEqual(len(response), 29) # TODO: adjust test dataset (belongs_to)
|
||||
self.assertEqual(len(response), 28)
|
||||
|
||||
def test_filter_has_installed(self):
|
||||
"""Filter StockItem by has_installed."""
|
||||
response = self.get_stock(has_installed_items=True)
|
||||
self.assertEqual(len(response), 0)
|
||||
self.assertEqual(len(response), 1)
|
||||
|
||||
response = self.get_stock(has_installed_items=False)
|
||||
self.assertEqual(len(response), 29) # TODO: adjust test dataset (belongs_to)
|
||||
self.assertEqual(len(response), 28)
|
||||
|
||||
def test_filter_has_child_items(self):
|
||||
"""Filter StockItem by has_child_items."""
|
||||
@@ -1881,6 +1891,58 @@ class StockTestResultTest(StockAPITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertGreaterEqual(len(response.data), 4)
|
||||
|
||||
def test_stock_item_and_include_installed_filters(self):
|
||||
"""Test stock_item filter with and without include_installed option."""
|
||||
url = self.get_url()
|
||||
|
||||
# Filter by stock_item without include_installed
|
||||
response = self.client.get(url, data={'stock_item': 105})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
initial_count = len(response.data)
|
||||
self.assertGreaterEqual(initial_count, 4)
|
||||
|
||||
# Verify all results belong to stock_item 105
|
||||
for result in response.data:
|
||||
self.assertEqual(result['stock_item'], 105)
|
||||
|
||||
# Filter by stock_item with include_installed=True
|
||||
response = self.client.get(
|
||||
url, data={'stock_item': 105, 'include_installed': True}
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
installed_count = len(response.data)
|
||||
self.assertGreaterEqual(installed_count, initial_count)
|
||||
|
||||
# Filter by stock_item with include_installed=False (explicit)
|
||||
response = self.client.get(
|
||||
url, data={'stock_item': 105, 'include_installed': False}
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), initial_count)
|
||||
|
||||
# Invalid stock_item ID (does not exist) - should return 400
|
||||
response = self.client.get(url, data={'stock_item': 999999})
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Non-numeric stock_item ID - should return 400
|
||||
response = self.client.get(url, data={'stock_item': 'invalid'})
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Filter by another stock_item (522)
|
||||
response = self.client.get(url, data={'stock_item': 522})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
item_522_count = len(response.data)
|
||||
self.assertGreaterEqual(item_522_count, 4)
|
||||
|
||||
# Verify all results belong to stock_item 522
|
||||
for result in response.data:
|
||||
self.assertEqual(result['stock_item'], 522)
|
||||
|
||||
# include_installed without stock_item (should be ignored)
|
||||
response_all = self.client.get(url)
|
||||
response_with_flag = self.client.get(url, data={'include_installed': True})
|
||||
self.assertEqual(len(response_all.data), len(response_with_flag.data))
|
||||
|
||||
def test_post_fail(self):
|
||||
"""Test failing posts."""
|
||||
# Attempt to post a new test result without specifying required data
|
||||
@@ -2129,6 +2191,22 @@ class StockTestResultTest(StockAPITestCase):
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
def test_output_options(self):
|
||||
"""Test output options for single item retrieval."""
|
||||
url = reverse('api-stock-test-result-detail', kwargs={'pk': 1})
|
||||
|
||||
response = self.get(url, {'user_detail': 'true'}, expected_code=200)
|
||||
self.assertIn('user_detail', response.data)
|
||||
|
||||
response = self.get(url, {'user_detail': 'false'}, expected_code=200)
|
||||
self.assertNotIn('user_detail', response.data)
|
||||
|
||||
response = self.get(url, {'template_detail': 'true'}, expected_code=200)
|
||||
self.assertIn('template_detail', response.data)
|
||||
|
||||
response = self.get(url, {'template_detail': 'false'}, expected_code=200)
|
||||
self.assertNotIn('template_detail', response.data)
|
||||
|
||||
|
||||
class StockTrackingTest(StockAPITestCase):
|
||||
"""Tests for the StockTracking API endpoints."""
|
||||
@@ -2224,6 +2302,25 @@ class StockTrackingTest(StockAPITestCase):
|
||||
self.assertIn(key, deltas)
|
||||
self.assertIsNotNone(deltas.get(key, None))
|
||||
|
||||
def test_output_options(self):
|
||||
"""Test output options."""
|
||||
url = self.get_url()
|
||||
response = self.client.get(
|
||||
url, {'item_detail': True, 'user_detail': True, 'limit': 2}
|
||||
)
|
||||
|
||||
for item in response.data['results']:
|
||||
self.assertIn('item_detail', item)
|
||||
self.assertIn('user_detail', item)
|
||||
|
||||
response = self.client.get(
|
||||
url, {'item_detail': False, 'user_detail': False, 'limit': 2}
|
||||
)
|
||||
|
||||
for item in response.data['results']:
|
||||
self.assertNotIn('item_detail', item)
|
||||
self.assertNotIn('user_detail', item)
|
||||
|
||||
|
||||
class StockAssignTest(StockAPITestCase):
|
||||
"""Unit tests for the stock assignment API endpoint, where stock items are manually assigned to a customer."""
|
||||
|
@@ -1469,7 +1469,7 @@ class TestResultTest(StockTestBase):
|
||||
|
||||
# Should return the same number of tests as before
|
||||
tests = item.testResultMap(include_installed=True)
|
||||
self.assertEqual(len(tests), 3)
|
||||
self.assertEqual(len(tests), 6)
|
||||
|
||||
if template := PartTestTemplate.objects.filter(
|
||||
part=item.part, key='somenewtest'
|
||||
@@ -1490,7 +1490,7 @@ class TestResultTest(StockTestBase):
|
||||
)
|
||||
|
||||
tests = item.testResultMap(include_installed=True)
|
||||
self.assertEqual(len(tests), 4)
|
||||
self.assertEqual(len(tests), 7)
|
||||
|
||||
self.assertIn('somenewtest', tests)
|
||||
self.assertEqual(sub_item.test_results.count(), 2)
|
||||
|
Reference in New Issue
Block a user